diff --git a/.example.env b/.example.env
deleted file mode 100644
index 22d7cd2..0000000
--- a/.example.env
+++ /dev/null
@@ -1,46 +0,0 @@
-######################################################################
-# Example Environment Configuration (DO NOT PUT REAL SECRETS) #
-# Copy to `.env` (git-ignored) and replace placeholder values. #
-# #
-# NEVER commit real API keys or tokens. #
-# Loaded automatically via python-dotenv. #
-# Only populate the sections relevant to the providers you use. #
-######################################################################
-
-#############################
-# Azure OpenAI / OpenAI API #
-#############################
-# Base URL of your Azure OpenAI resource (Ends with .openai.azure.com/)
-AZURE_API_BASE=https://.openai.azure.com/
-# One of the two keys under Azure OpenAI: Portal > Resource > Develop > Keys & Endpoint
-AZURE_OPENAI_API_KEY=
-# API version (update only if you intentionally target a newer preview/stable)
-AZURE_API_VERSION=2025-04-01-preview
-
-# Direct OpenAI key (use only if calling OpenAI instead of Azure deployment routing)
-OPENAI_API_KEY=
-
-# Mistral key from https://console.mistral.ai (Workspace > API keys)
-MISTRAL_API_KEY=
-
-#############################
-# Local models through vLLM and HuggingFace #
-#############################
-# Port where your local vLLM server will be exposed. Any free port larger than 1024
-# and less than 65535 should work.
-LOCAL_VLLM_PORT=9000
-
-# Hugging Face access token (Settings > Access Tokens). Needed for private/gated models
-HF_TOKEN=
-
-#######################################
-# Azure Document Intelligence, if you are using Azure OCR
-#######################################
-# Endpoint of your Document Intelligence resource
-AZURE_DI_ENDPOINT=https://.cognitiveservices.azure.com
-# One of the two keys under that resource (Keys & Endpoint)
-AZURE_DOC_KEY=
-
-# Vertex AI settings
-# Location of your Vertex AI resources, e.g. us-central1, us-east5, global
-VERTEX_AI_LOCATION=
\ No newline at end of file
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 0000000..476bb3c
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,62 @@
+name: docs
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: docs-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v5
+
+ - name: Set up Pixi
+ uses: prefix-dev/setup-pixi@v0.9.3
+ with:
+ pixi-version: v0.66.0
+ cache: true
+
+ - name: Build documentation
+ run: pixi run docs-build
+
+ - name: Configure GitHub Pages
+ if: github.event_name != 'pull_request'
+ uses: actions/configure-pages@v5
+
+ - name: Upload Pages artifact
+ if: github.event_name != 'pull_request'
+ uses: actions/upload-pages-artifact@v4
+ with:
+ path: docs/_build/html
+
+ deploy:
+ if: github.event_name != 'pull_request'
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..da3f772
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,73 @@
+name: publish
+
+on:
+ release:
+ types:
+ - published
+
+permissions:
+ contents: read
+
+concurrency:
+ group: publish-${{ github.event.release.tag_name }}
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v5
+
+ - name: Set up Pixi
+ uses: prefix-dev/setup-pixi@v0.9.3
+ with:
+ pixi-version: v0.66.0
+ cache: true
+
+ - name: Verify release tag matches package version
+ run: |
+ python - <<'PY'
+ import os
+ import tomllib
+ from pathlib import Path
+
+ pyproject = tomllib.loads(Path("pyproject.toml").read_text())
+ version = pyproject["project"]["version"]
+ tag = os.environ["GITHUB_REF_NAME"]
+ expected = f"v{version}"
+ if tag != expected:
+ raise SystemExit(
+ f"Release tag {tag!r} does not match project.version {expected!r}."
+ )
+ PY
+
+ - name: Build and validate release artifacts
+ run: pixi run package-check
+
+ - name: Upload distributions
+ uses: actions/upload-artifact@v4
+ with:
+ name: python-package-distributions
+ path: dist/
+
+ publish:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: pypi
+ url: https://pypi.org/p/churro-ocr
+ permissions:
+ contents: read
+ id-token: write
+
+ steps:
+ - name: Download distributions
+ uses: actions/download-artifact@v4
+ with:
+ name: python-package-distributions
+ path: dist/
+
+ - name: Publish to PyPI
+ uses: pypa/gh-action-pypi-publish@release/v1
diff --git a/.gitignore b/.gitignore
index 313ed49..b5e4439 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,173 +1,25 @@
-# Byte-compiled / optimized / DLL files
+# Python caches
__pycache__/
*.py[cod]
-*$py.class
-
-# C extensions
*.so
+*.pyd
+.mypy_cache/
+.pytest_cache/
+.ruff_cache/
+.litellm_cache/
+.cache/
-# Distribution / packaging
-.Python
+# Local environments and build artifacts
+.pixi/
+.coverage
build/
-develop-eggs/
dist/
-downloads/
-eggs/
-.eggs/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-# PyInstaller
-# Usually these files are written by a python script from a template
-# before PyInstaller builds the exe, so as to inject date/other infos into it.
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-cover/
-
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
docs/_build/
+*.egg-info/
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pyenv
-# For a library or package, you might want to ignore these files since the code is
-# intended to run in multiple environments; otherwise, check them in:
-# .python-version
-
-# pipenv
-# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
-# However, in case of collaboration, if having platform-specific dependencies or dependencies
-# having no cross-platform support, pipenv may install dependencies that don't work, or not
-# install all needed dependencies.
-#Pipfile.lock
-
-# poetry
-# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
-# This is especially recommended for binary packages to ensure reproducibility, and is more
-# commonly ignored for libraries.
-# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
-#poetry.lock
-
-# pdm
-# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
-#pdm.lock
-# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
-# in version control.
-# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
-.pdm.toml
-.pdm-python
-.pdm-build/
-
-# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
-.env
-.venv
-env/
-venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
-
-# PyCharm
-# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
-# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
-# and can be added to the global gitignore or merged into this file. For a more nuclear
-# option (not recommended) you can uncomment the following to ignore the entire idea folder.
-#.idea/
-
-.vscode
-
-# pixi environments
-.pixi/
-
-# Runtime artifacts
+# Local data and logs
workdir/
workdir
-prompt_logs.jsonl
-.litellm_cache/
-.diskcache/
\ No newline at end of file
+debug.log
+debug_logs*
+.env
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
new file mode 100644
index 0000000..39b888c
--- /dev/null
+++ b/.pre-commit-config.yaml
@@ -0,0 +1,59 @@
+minimum_pre_commit_version: 4.0.0
+default_install_hook_types:
+ - pre-commit
+ - pre-push
+
+repos:
+ - repo: local
+ hooks:
+ - id: format
+ name: pixi format
+ entry: pixi run format
+ language: system
+ pass_filenames: false
+ stages:
+ - pre-commit
+ files: ^(src/|tests/|pyproject\.toml|pixi\.lock|pytest\.ini|ruff\.toml|ty\.toml)
+
+ - id: lint
+ name: pixi lint
+ entry: pixi run lint
+ language: system
+ pass_filenames: false
+ stages:
+ - pre-commit
+ files: ^(src/|tests/|pyproject\.toml|pixi\.lock|pytest\.ini|ruff\.toml|ty\.toml)
+
+ - id: typecheck
+ name: pixi typecheck
+ entry: pixi run typecheck
+ language: system
+ pass_filenames: false
+ stages:
+ - pre-commit
+ files: ^(src/|tests/|pyproject\.toml|pixi\.lock|pytest\.ini|ruff\.toml|ty\.toml)
+
+ - id: test
+ name: pixi test
+ entry: pixi run test
+ language: system
+ pass_filenames: false
+ stages:
+ - pre-push
+ files: ^(src/|tests/|pyproject\.toml|pixi\.lock|pytest\.ini|ruff\.toml|ty\.toml)
+
+ - id: docs-build
+ name: pixi docs-build
+ entry: pixi run docs-build
+ language: system
+ pass_filenames: false
+ stages:
+ - manual
+
+ - id: package-check
+ name: pixi package-check
+ entry: pixi run package-check
+ language: system
+ pass_filenames: false
+ stages:
+ - manual
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..ce4df58
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,30 @@
+# AGENTS
+
+This repo is `churro-ocr`, a Python 3.12 OCR and page-detection toolkit for historical documents.
+
+## Repo Layout
+
+- `src/churro_ocr/`: library code and CLI entrypoints
+- `tests/`: pytest suite and test assets
+- `docs/`: Sphinx documentation source
+- `scripts/` and `tooling/`: packaging, benchmarking, and evaluation helpers
+
+## Preferred Workflow
+
+Use Pixi from the repo root:
+
+```bash
+pixi install
+pixi run format
+pixi run lint
+pixi run typecheck
+pixi run test
+pixi run docs-build
+pixi run package-check
+```
+
+## Guardrails
+
+- Prefer the Pixi tasks above over ad hoc commands.
+- Keep library changes in `src/churro_ocr/` and add or update tests in `tests/`.
+- Do not enable live integration tests unless the task explicitly calls for them and the required credentials are available.
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..5275805
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,9 @@
+include LICENSE
+include docs/pypi.md
+include pyproject.toml
+graft src/churro_ocr
+prune scripts
+prune tests
+prune tooling
+global-exclude __pycache__
+global-exclude *.py[cod]
diff --git a/README.md b/README.md
index 3dcb8af..f133067 100644
--- a/README.md
+++ b/README.md
@@ -1,291 +1,37 @@
-
-
-
CHURRO: Making History Readable with an Open-Weight Large Vision-Language Model for High-Accuracy, Low-Cost Historical Text Recognition
-
-
-
-
-
-
-
-
-
- Handwritten and printed text recognition across 22 centuries and 46 language clusters, including historical and dead languages.
-
-
-
-
-
- Cost vs. accuracy: CHURRO (3B) achieves higher accuracy than much larger commercial and open-weight VLMs while being substantially cheaper.
-
-
----
-## Table of Contents
-1. [Overview](#overview)
-2. [Quick Start](#quick-start)
-3. [Installing the Full Package](#installing-the-full-package)
- - [System Packages](#system-packages)
- - [Docker (recommended for local models)](#docker-recommended-for-local-models)
- - [Environment Setup](#environment-setup)
- - [Configure Providers](#configure-providers)
-4. [CLI Workflows](#cli-workflows)
- - [Inference](#inference)
- - [Preprocess PDFs and Images](#preprocess-pdfs-and-images)
- - [Benchmark on CHURRO-DS](#benchmark-on-churro-ds)
- - [LLM Improver](#llm-improver)
- - [Backup Engines](#backup-engines)
- - [Local vLLM Container Notes](#local-vllm-container-notes)
-5. [Adding a New OCR System](#adding-a-new-ocr-system)
-6. [HistoricalDocument XML](#historicaldocument-xml)
- - [Generate HistoricalDocument XML](#generate-historicaldocument-xml)
-7. [Citation](#citation)
-8. [License](#license)
-
----
-
-## Overview
-**CHURRO** is a 3B-parameter open-weight vision-language model (VLM) for historical document transcription. It is trained on **CHURRO-DS**, a curated dataset of ~100K pages from 155 historical collections spanning 22 centuries and 46 language clusters.
-
-On the CHURRO-DS test set, CHURRO delivers **15.5× lower cost than Gemini 2.5 Pro while exceeding its accuracy**.
-
-## Quick Start
-
-Want a minimal demo? The following will install `transformers` and `torch` only:
-```bash
-git clone https://github.com/stanford-oval/churro.git
-cd churro
-curl -fsSL https://pixi.sh/install.sh | bash
-pixi shell -e minimal
-```
-
-Then run:
-```bash
-python churro_transformers_infer.py tests/churro_dataset_sample_1.jpeg --max-new-tokens 40
-```
-
-Expected output begins with:
-
-```xml
-
-
- German
- ltr
- [!WARNING]
-> This codebase has been tested on Ubuntu 20.04+. Using other operating systems may require tinkering with and troubleshooting system dependencies.
-
-### System Packages
-```bash
-sudo apt-get update && sudo apt-get install -y \
- libtiff5-dev libjpeg8-dev libopenjp2-7-dev zlib1g-dev libfreetype6-dev \
- liblcms2-dev libwebp-dev tcl8.6-dev tk8.6-dev python3-tk libharfbuzz-dev \
- libfribidi-dev libxcb1-dev
-```
-
-### Docker (recommended for local models)
-- Install Docker: https://docs.docker.com/engine/install/
-- GPU users: add the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html).
-- CPU-only machines can still run local models, but expect significantly slower throughput.
-
-### Environment Setup
-We use [Pixi](https://pixi.sh/) to manage Python environments and dependencies. If you are familiar with [Conda](https://docs.conda.io/), you can think of Pixi as a much faster alternative. The following commands set up a Pixi shell with all required packages. Make sure the environment is active before running any Python code.
-```bash
-git clone https://github.com/stanford-oval/churro.git
-cd churro
-curl -fsSL https://pixi.sh/install.sh | bash
-pixi shell # create and enter the managed environment
-```
-
-Sanity check the install with:
-```bash
-pixi run python -m churro.cli --help
-```
-
-### Configure Providers
-
-Copy the example environment file:
-```bash
-cp .example.env .env
-```
-Populate only the variables you need in `.env`.
-All environment variables live in `.env` and are autoloaded via `python-dotenv`. Use the table below as a quick reference to decide which credentials you must supply.
+# CHURRO
-| Workflow | Required providers | Key variables |
-|----------|-------------------|---------------|
-| Azure Document Intelligence OCR (`--system azure`) or if using `docs-to-images` command without `--no-trim` | Azure Document Intelligence | `AZURE_DI_ENDPOINT`, `AZURE_DOC_KEY` |
-| LLM-based OCR against Vertex AI deployments | Google Vertex AI | `VERTEX_AI_LOCATION` |
-| LLM-based OCR against Azure/OpenAI deployments | Azure OpenAI or OpenAI | `AZURE_API_BASE`, `AZURE_OPENAI_API_KEY` (or `OPENAI_API_KEY`), `AZURE_API_VERSION` |
-| Mistral OCR (`--system mistral_ocr`) | Mistral | `MISTRAL_API_KEY` |
-| Local vLLM models (`--system finetuned` or `llm` with engines backed by `vllm/`) | Docker + Hugging Face | `LOCAL_VLLM_PORT`, `HF_TOKEN` (only if using private models) |
+CHURRO is an OCR toolkit for historical document transcription, built to make handwritten and printed sources readable at high accuracy and lower cost.
-When a workflow does not need a provider, leave the corresponding variables blank. See `.example.env` for full documentation of each field.
-For Vertex AI usage, additionally ensure that the Google Cloud SDK is installed and authenticated: https://cloud.google.com/sdk/docs/install
+It works with all major OCR proividers and vision-language models, and provides first-party support for the CHURRO 3B model and CHURRO-DS dataset.
-Note that for all API LLM calls, the outputs are cached in `.litellm_cache/`, so subsequent runs with the same inputs will be much faster and free.
+[](https://huggingface.co/stanford-oval/churro-3B)
+[](https://huggingface.co/datasets/stanford-oval/churro-dataset)
+[](https://arxiv.org/abs/2509.19768)
+[](https://stanford-oval.github.io/Churro/)
+[](https://stanford-oval.github.io/Churro/leaderboard.html)
+[](https://github.com/stanford-oval/churro/stargazers)
-## CLI Workflows
-The unified Typer CLI lives under `churro/cli`. All examples below assume you are inside a `pixi shell` or prefix commands with `pixi run`.
-### Inference
-Single image (local CHURRO model hosted via vLLM):
-```bash
-pixi run python -m churro.cli infer \
- --system finetuned \
- --engine churro \
- --image tests/churro_dataset_sample_1.jpeg
-```
-
-`finetuned` system returns HistoricalDocument XML by default; add `--strip-xml` to output plain text instead. See [HistoricalDocument XML](#historicaldocument-xml) for schema details and parsing tips.
-
-Optionally, add `--binarize` to pre-process each page with the bundled neural image binarizer before sending it to OCR. This can improve OCR accuracy on degraded documents.
-The first run downloads the `stanford-oval/eynollah_binarizer_onnx` model.
-
-Batch directory with filtered suffixes and output files:
-```bash
-pixi run python -m churro.cli infer \
- --system finetuned \
- --engine churro \
- --image-dir path/to/images \
- --suffix png --suffix jpeg \
- --recursive \
- --output-dir workdir/texts/ \
- --skip-existing \
- --max-concurrency 8
-```
-
-Use `pixi run python -m churro.cli infer --help` to see every option, including how to use other LLMs via `--system llm --engine ` arguments.
-
-### Preprocess PDFs and Images
-If you have raw PDF scans or image directories, first use the `docs-to-images` command to convert them into page-aligned PNGs ready for OCR.
-`docs-to-images` normalizes PDF scans and image directories into page-aligned PNGs. The default engine `gemini-2.5-pro-low` calls a Vertex AI model to detect double-page spreads, then calls Azure Document Intelligence to detect page boundaries and trim margins.
-
-Single PDF:
-```bash
-pixi run python -m churro.cli docs-to-images \
- --input-file path/to/file.pdf \
- --output-dir workdir/images/
-```
-
-Mixed directory with custom suffix filters and dry run:
-```bash
-pixi run python -m churro.cli docs-to-images \
- --input-dir path/to/scans \
- --suffix pdf --suffix tif --suffix png \
- --recursive \
- --output-dir workdir/images/ \
- --dry-run
-```
-
-Here is how this pipeline works:
-- An LLM estimates whether a rasterized page contains a two-page spread. Provide `--engine ` to swap to a different splitter if you do not have Vertex AI access.
-- Margin trimming is enabled by default via Azure Document Intelligence. Use `--no-trim` to disable this stage.
-- `--batch-pages`, `--queue-maxsize`, `--raster-workers`, `--page-workers`, and `--llm-concurrency-limit` balance CPU-bound rasterization and LLM throughput.
-- Pages are written as `_page_XXXX.png`, even when spreads split into multiple images.
-
-### Benchmark on CHURRO-DS
-Run end-to-end evaluation against the CHURRO dataset. The command automatically initializes any required local vLLM server before processing.
-```bash
-pixi run python -m churro.cli benchmark \
- --system finetuned \
- --engine churro \
- --dataset-split test \
- --input-size 0 \
- --max-concurrency 32
-```
-
-Important options:
-- `--system {azure,mistral_ocr,llm,finetuned}` determines which OCR backend to use.
-- `--engine ` is required for `llm` and `finetuned` systems; see `churro/utils/llm/models.py` for the full `MODEL_MAP` of logical keys (GPT-4/5, Claude, Gemini, Qwen 2.5, MiniCPM, CHURRO, and more).
-- `--tensor-parallel-size` / `--data-parallel-size` tune vLLM scaling for local engines.
-- `--resize ` optionally resizes large images before inference.
-
-Optionally, add `--binarize` to pre-process each dataset page with a neural image binarizer before OCR.
+- 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.
-Outputs land under `workdir/results//_/` (the engine suffix is omitted for `azure` and `mistral_ocr`).
-
-
-### LLM Improver
-The Churro CLI supports optional post-processing with the `LLMImprover`, enabled via `--use-improver`. Pair it with `--improver-engine`. Improver can help fix OCR errors, and improve the formatting of complex documents' Markdown.
-
-### Backup Engines
-You can supply a backup engine for LLM-based OCR systems using `--backup-engine`, and for LLM improvers using `--improver-backup-engine`.
-Both backup options allow the pipeline to retry with a secondary model if the first call fails. For example, when a provider's content filter incorrectly flags historical material or when a transient outage interrupts inference.
-
-
-### Local vLLM Container Notes
-When you run `infer` or `benchmark` with an `llm` or `finetuned` system whose engine has an `hf_repo` entry, the CLI will:
-- Read `LOCAL_VLLM_PORT` and `HF_TOKEN` from your environment (`churro/utils/docker/vllm.py`).
-- Pull the corresponding Hugging Face repository on first launch. Expect multi-gigabyte downloads.
-- Start a Docker container exposing an OpenAI-compatible API at `http://localhost:/v1`.
-- Stop the container automatically when the command exits or crashes.
-
-Make sure the chosen port is free and that Docker is running. GPU acceleration is optional but dramatically improves throughput.
-
-## Adding a New OCR System
-Pull requests for new VLMs and OCR backends are welcome.
-
-If adding a new LLM, simply add it to `utils/llm/models.py` (`MODEL_MAP`). Include an `hf_repo` for vLLM-served models.
-
-For entirely new OCR systems, follow all steps:
-1. Register the system in `churro/systems/ocr_factory.py` so the CLI can instantiate it.
-2. Implement `process_image` and `get_system_name` in a subclass of `BaseOCR`.
-3. Use `--system ` with the CLI or import the factory in your own scripts.
-
-## HistoricalDocument XML
-`HistoricalDocument` is the XML schema we use in the CHURRO dataset and model for rich transcriptions. It is specifically designed to capture complex layouts, scribal edits, and missing text, which are all common in historical documents, while preserving reading order.
-
-Each response contains a root `` element with optional `` details (languages, scripts, writing direction, notes) followed by one or more `` blocks. A page combines optional `` and `` regions with a required `` that nests structural tags such as ``, ``, ``, and ``. Inline markup like ``, ``, ` `, and `` captures scribal edits or missing text while preserving reading order.
-
-```xml
-
-
- lat
-
-
-
-
-
-
- In nomine domini amen.
- nos notarii subscripsimus.
-
-
-
-
-```
-
-The complete definition lives in `churro/evaluation/historical_doc.xsd`. The inference CLI's `--strip-xml` flag and the evaluation helpers call `churro.evaluation.xml_utils.extract_actual_text_from_xml()` to remove all XML tags and flatten the content into plain text when you do not need the markup.
+
+
+
+ Cost vs. accuracy: CHURRO (3B) achieves higher accuracy than much larger commercial and open-weight VLMs while being substantially cheaper.
+
-### Generate HistoricalDocument XML
-If you are adding a new dataset to Churro, you may want to convert your transcriptions to the `HistoricalDocument` XML format.
-Convert a directory full of PNG/TXT pairs into `HistoricalDocument` XML using this CLI tool. This conversion uses an LLM prompt to structure the text according to the schema.
+## Quick Try
```bash
-pixi run python -m churro.cli text-to-historical-doc-xml \
- path/to/pairs/dir \
- --corpus-description "Basque newspaper corpus" \
- --max-concurrency 8
+pip install "churro-ocr[hf]"
+churro-ocr transcribe --image scan.png --backend hf --model stanford-oval/churro-3B
```
-Place your data in matched `X.png` and `X.txt` files within the input directory; each pair yields an `X.xml`. The tool:
-- Validates LLM output against `historical_doc.xsd` and prettifies XML prior to saving.
-- Skips files that already have XML unless you pass `--overwrite`.
-- Accepts any logical engine key from `MODEL_MAP` via `--engine` (defaults to `gemini-2.5-pro-medium`).
-- Adds optional corpus context to prompts through `--corpus-description`.
-
----
+For more in-depth information, see the [Getting Started](https://stanford-oval.github.io/Churro/getting-started.html) guide.
## Citation
+
If you use CHURRO or CHURRO-DS, please cite:
```bibtex
@@ -297,9 +43,8 @@ If you use CHURRO or CHURRO-DS, please cite:
}
```
----
-
## License
-- Model Weights: Qwen research license (see HF model card)
-- Dataset: Due to licensing restrictions on the original datasets used in Churro, use is permitted for research purposes only.
+
+- Model weights: Qwen research license
+- Dataset: research use only because of the underlying source licenses
- Code: Apache 2.0
diff --git a/__init__.py b/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/args.py b/args.py
deleted file mode 100644
index d48b395..0000000
--- a/args.py
+++ /dev/null
@@ -1,145 +0,0 @@
-import argparse
-from pathlib import Path
-import shutil
-import sys
-
-from churro.utils.llm.models import MODEL_MAP
-from churro.utils.log_utils import logger
-
-
-# Central place to define canonical HF identifiers
-CHURRO_DATASET_ID: str = "stanford-oval/churro-dataset"
-
-
-def build_parser(*, add_help: bool = True) -> argparse.ArgumentParser:
- """Create the benchmark CLI argument parser without parsing."""
- parser = argparse.ArgumentParser(add_help=add_help)
-
- parser.add_argument(
- "--system",
- required=True,
- choices=[
- "azure",
- "mistral_ocr",
- "llm",
- "finetuned",
- ],
- help="Specify the system to run.",
- )
- parser.add_argument(
- "--engine",
- type=str,
- default=None,
- help="For LLM baseline, specify the LLM to use.",
- )
- parser.add_argument(
- "--tensor-parallel-size",
- type=int,
- default=1,
- help="Tensor parallel size. Only used for local models.",
- )
- parser.add_argument(
- "--data-parallel-size",
- type=int,
- default=1,
- help="Data parallel size. Only used for local models.",
- )
- parser.add_argument(
- "--resize",
- type=int,
- default=None,
- help="If set, will resize large images to fit inside a square of this size (in pixels). ",
- )
- parser.add_argument(
- "--max-concurrency",
- type=int,
- default=50,
- help="Maximum number of LLM requests to allow at once.",
- )
- parser.add_argument(
- "--input-size",
- type=int,
- default=0,
- help="Number of images to process. 0 means all images.",
- )
- parser.add_argument(
- "--dataset-split",
- required=True,
- type=str,
- choices=["dev", "test"],
- help="Data split to use.",
- )
- parser.add_argument("--offset", type=int, default=0, help="Offset for the input images.")
-
- return parser
-
-
-def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
- """Parse command line arguments and validate."""
- parser = build_parser()
- args = parser.parse_args(argv)
- _validate_args(args)
- return args
-
-
-def _validate_args(args: argparse.Namespace) -> None:
- """Validate argument combinations."""
- if args.system == "llm":
- assert args.engine is not None, "LLM engine must be specified for LLM baseline."
- valid_engines = ", ".join(sorted(MODEL_MAP.keys()))
- assert args.engine in MODEL_MAP, (
- f"Invalid engine: {args.engine}. Possible values are: {valid_engines}"
- )
-
-
-def create_output_prefix(args: argparse.Namespace) -> str:
- """Create output directory path based on arguments."""
- base_workdir = Path(__file__).resolve().parent / "workdir"
- output_dir = base_workdir / "results" / args.dataset_split / args.system
-
- if args.system not in ["azure", "mistral_ocr"]:
- engine_name = args.engine
- if "/" in engine_name:
- engine_name = args.engine.split("/")
- engine_name = [p for p in engine_name if "workdir" not in p and p]
- engine_name = "_".join(engine_name)
- output_dir = output_dir.parent / f"{output_dir.name}_{engine_name}"
-
- # If directory exists already, warn user and exit to avoid overwriting prior results.
- if output_dir.exists():
- try:
- # Abort (after confirmation) only if directory exists AND is not empty.
- if any(output_dir.iterdir()):
- if not sys.stdin.isatty():
- logger.warning(
- f"Output directory '{output_dir}' already exists and is not empty, "
- "but cannot prompt in non-interactive mode. Aborting to avoid overwrite."
- )
- raise SystemExit(1)
- response = (
- input(
- f"Output directory '{output_dir}' already exists and is not empty. "
- "Overwrite (this will delete existing contents)? [y/N]: "
- )
- .strip()
- .lower()
- )
- if response in {"y", "yes"}:
- try:
- shutil.rmtree(output_dir)
- output_dir.mkdir(parents=True, exist_ok=True)
- logger.info(f"Overwrote existing directory '{output_dir}'.")
- except OSError as exc:
- logger.error(f"Failed to overwrite directory '{output_dir}': {exc}")
- raise SystemExit(1) from exc
- else:
- logger.info("User declined overwrite. Aborting.")
- raise SystemExit(1)
- # Directory exists but is empty: reuse it.
- except OSError as exc:
- logger.warning(f"Could not inspect contents of '{output_dir}'. Aborting for safety.")
- raise SystemExit(1) from exc
- else:
- output_dir.mkdir(parents=True, exist_ok=True)
-
- return str(output_dir)
diff --git a/benchmark_results.json b/benchmark_results.json
new file mode 100644
index 0000000..0fc66a2
--- /dev/null
+++ b/benchmark_results.json
@@ -0,0 +1,353 @@
+[
+ {
+ "modelName": "Churro",
+ "modelId": "stanford-oval/churro-3B",
+ "modelUrl": "https://huggingface.co/stanford-oval/churro-3B",
+ "hasIcon": true,
+ "printed": 82.3309,
+ "handwritten": 70.0965,
+ "total": 75.7431
+ },
+ {
+ "modelName": "Gemini 3 Flash",
+ "modelId": "gemini-3-flash",
+ "modelUrl": "https://ai.google.dev/gemini-api/docs/models",
+ "hasIcon": false,
+ "printed": 82.6,
+ "handwritten": 66.5,
+ "total": 73.9
+ },
+ {
+ "modelName": "Gemini 3 Pro",
+ "modelId": "gemini-3-pro",
+ "modelUrl": "https://ai.google.dev/gemini-api/docs/models",
+ "hasIcon": false,
+ "printed": 78.2,
+ "handwritten": 66.7,
+ "total": 72.0
+ },
+ {
+ "modelName": "Gemini 2.5 Pro",
+ "modelId": "gemini-2.5-pro",
+ "modelUrl": "https://ai.google.dev/gemini-api/docs/models",
+ "hasIcon": false,
+ "printed": 80.8523,
+ "handwritten": 63.6329,
+ "total": 71.5803
+ },
+ {
+ "modelName": "Gemini 2.5 Flash",
+ "modelId": "gemini-2.5-flash",
+ "modelUrl": "https://ai.google.dev/gemini-api/docs/models",
+ "hasIcon": false,
+ "printed": 73.719,
+ "handwritten": 58.7283,
+ "total": 65.6471
+ },
+ {
+ "modelName": "Qwen 3 VL (8B)",
+ "modelId": "Qwen/Qwen3-VL-8B-Instruct",
+ "modelUrl": "https://huggingface.co/Qwen/Qwen3-VL-8B-Instruct",
+ "hasIcon": false,
+ "printed": 76.6136,
+ "handwritten": 48.4963,
+ "total": 61.4735
+ },
+ {
+ "modelName": "Qwen 3 VL (30B-A3B)",
+ "modelId": "Qwen/Qwen3-VL-30B-A3B-Instruct",
+ "modelUrl": "https://huggingface.co/Qwen/Qwen3-VL-30B-A3B-Instruct",
+ "hasIcon": false,
+ "printed": 74.8873,
+ "handwritten": 49.558,
+ "total": 61.2485
+ },
+ {
+ "modelName": "NuMarkdown",
+ "modelId": "numind/NuMarkdown-8B-Thinking",
+ "modelUrl": "https://huggingface.co/numind/NuMarkdown-8B-Thinking",
+ "hasIcon": false,
+ "printed": 72.7318,
+ "handwritten": 51.2224,
+ "total": 61.1498
+ },
+ {
+ "modelName": "GPT-4.1 Mini",
+ "modelId": "gpt-4.1-mini-2025-04-14",
+ "modelUrl": "https://platform.openai.com/docs/models/gpt-4.1-mini",
+ "hasIcon": false,
+ "printed": 73.1042,
+ "handwritten": 50.2487,
+ "total": 60.7974
+ },
+ {
+ "modelName": "Qwen 2.5 VL (72B)",
+ "modelId": "Qwen/Qwen2.5-VL-72B-Instruct",
+ "modelUrl": "https://huggingface.co/Qwen/Qwen2.5-VL-72B-Instruct",
+ "hasIcon": false,
+ "printed": 66.2783,
+ "handwritten": 54.4717,
+ "total": 59.9209
+ },
+ {
+ "modelName": "Azure OCR",
+ "modelId": null,
+ "modelUrl": null,
+ "hasIcon": false,
+ "printed": 71.8617,
+ "handwritten": 47.7443,
+ "total": 58.8754
+ },
+ {
+ "modelName": "GPT-5.2",
+ "modelId": "gpt-5.2-2025-12-11",
+ "modelUrl": "https://platform.openai.com/docs/models/gpt-5.2",
+ "hasIcon": false,
+ "printed": 71.4,
+ "handwritten": 46.8,
+ "total": 58.2
+ },
+ {
+ "modelName": "GPT-5 Mini",
+ "modelId": "gpt-5-mini-2025-08-07",
+ "modelUrl": "https://platform.openai.com/docs/models/gpt-5-mini",
+ "hasIcon": false,
+ "printed": 69.8107,
+ "handwritten": 47.5121,
+ "total": 57.8038
+ },
+ {
+ "modelName": "Claude Sonnet 3.7",
+ "modelId": "claude-3-7-sonnet-20250219",
+ "modelUrl": "https://docs.claude.com/en/docs/about-claude/models/overview",
+ "hasIcon": false,
+ "printed": 70.1866,
+ "handwritten": 46.5904,
+ "total": 57.481
+ },
+ {
+ "modelName": "RolmOCR",
+ "modelId": "reducto/RolmOCR",
+ "modelUrl": "https://huggingface.co/reducto/RolmOCR",
+ "hasIcon": false,
+ "printed": 67.2281,
+ "handwritten": 49.0075,
+ "total": 57.417
+ },
+ {
+ "modelName": "Qwen 3 VL (4B)",
+ "modelId": "Qwen/Qwen3-VL-4B-Instruct",
+ "modelUrl": "https://huggingface.co/Qwen/Qwen3-VL-4B-Instruct",
+ "hasIcon": false,
+ "printed": 74.9451,
+ "handwritten": 40.6463,
+ "total": 56.4765
+ },
+ {
+ "modelName": "Nanonets OCR",
+ "modelId": "nanonets/Nanonets-OCR-s",
+ "modelUrl": "https://huggingface.co/nanonets/Nanonets-OCR-s",
+ "hasIcon": false,
+ "printed": 69.7033,
+ "handwritten": 43.1781,
+ "total": 55.4205
+ },
+ {
+ "modelName": "olmOCR",
+ "modelId": "allenai/olmOCR-7B-0825",
+ "modelUrl": "https://huggingface.co/allenai/olmOCR-7B-0825",
+ "hasIcon": false,
+ "printed": 69.766,
+ "handwritten": 41.5165,
+ "total": 54.5547
+ },
+ {
+ "modelName": "Qwen 2.5 VL (3B)",
+ "modelId": "Qwen/Qwen2.5-VL-3B-Instruct",
+ "modelUrl": "https://huggingface.co/Qwen/Qwen2.5-VL-3B-Instruct",
+ "hasIcon": false,
+ "printed": 67.8366,
+ "handwritten": 42.8576,
+ "total": 54.3864
+ },
+ {
+ "modelName": "O4 Mini",
+ "modelId": "o4-mini-2025-04-16",
+ "modelUrl": "https://platform.openai.com/docs/models/o4-mini",
+ "hasIcon": false,
+ "printed": 65.5032,
+ "handwritten": 41.4801,
+ "total": 52.5677
+ },
+ {
+ "modelName": "Claude Opus 4.1",
+ "modelId": "claude-opus-4-1-20250805",
+ "modelUrl": "https://docs.claude.com/en/docs/about-claude/models/overview",
+ "hasIcon": false,
+ "printed": 66.6052,
+ "handwritten": 40.1808,
+ "total": 52.3767
+ },
+ {
+ "modelName": "GPT-4.1",
+ "modelId": "gpt-4.1-2025-04-14",
+ "modelUrl": "https://platform.openai.com/docs/models/gpt-4.1",
+ "hasIcon": false,
+ "printed": 64.4017,
+ "handwritten": 41.4127,
+ "total": 52.023
+ },
+ {
+ "modelName": "Claude Sonnet 4",
+ "modelId": "claude-sonnet-4-20250514",
+ "modelUrl": "https://docs.claude.com/en/docs/about-claude/models/overview",
+ "hasIcon": false,
+ "printed": 62.2358,
+ "handwritten": 37.0701,
+ "total": 48.685
+ },
+ {
+ "modelName": "O1",
+ "modelId": "o1-2024-12-17",
+ "modelUrl": "https://platform.openai.com/docs/models/o1",
+ "hasIcon": false,
+ "printed": 62.4692,
+ "handwritten": 35.0806,
+ "total": 47.7215
+ },
+ {
+ "modelName": "O3",
+ "modelId": "o3-2025-04-16",
+ "modelUrl": "https://platform.openai.com/docs/models/o3",
+ "hasIcon": false,
+ "printed": 62.65,
+ "handwritten": 30.9051,
+ "total": 45.5566
+ },
+ {
+ "modelName": "Mistral OCR",
+ "modelId": null,
+ "modelUrl": null,
+ "hasIcon": false,
+ "printed": 64.0418,
+ "handwritten": 29.4388,
+ "total": 45.4094
+ },
+ {
+ "modelName": "GPT-5",
+ "modelId": "gpt-5-2025-08-07",
+ "modelUrl": "https://platform.openai.com/docs/models/gpt-5",
+ "hasIcon": false,
+ "printed": 60.7442,
+ "handwritten": 30.4073,
+ "total": 44.409
+ },
+ {
+ "modelName": "GPT-4o",
+ "modelId": "gpt-4o-2024-11-20",
+ "modelUrl": "https://platform.openai.com/docs/models/gpt-4o",
+ "hasIcon": false,
+ "printed": 56.2807,
+ "handwritten": 34.1932,
+ "total": 44.3875
+ },
+ {
+ "modelName": "MiMo VL",
+ "modelId": "XiaomiMiMo/MiMo-VL-7B-RL-2508",
+ "modelUrl": "https://huggingface.co/XiaomiMiMo/MiMo-VL-7B-RL-2508",
+ "hasIcon": false,
+ "printed": 54.8469,
+ "handwritten": 34.6299,
+ "total": 43.9608
+ },
+ {
+ "modelName": "Gemma 3 (27B)",
+ "modelId": "google/gemma-3-27b-it",
+ "modelUrl": "https://huggingface.co/google/gemma-3-27b-it",
+ "hasIcon": false,
+ "printed": 55.3292,
+ "handwritten": 34.1056,
+ "total": 43.9011
+ },
+ {
+ "modelName": "GPT-4o Mini",
+ "modelId": "gpt-4o-mini-2024-07-18",
+ "modelUrl": "https://platform.openai.com/docs/models/gpt-4o-mini",
+ "hasIcon": false,
+ "printed": 52.8335,
+ "handwritten": 29.7653,
+ "total": 40.4121
+ },
+ {
+ "modelName": "MiniCPM-V 4.5",
+ "modelId": "openbmb/MiniCPM-V-4_5",
+ "modelUrl": "https://huggingface.co/openbmb/MiniCPM-V-4_5",
+ "hasIcon": false,
+ "printed": 49.7495,
+ "handwritten": 32.0729,
+ "total": 40.2314
+ },
+ {
+ "modelName": "GPT-4.1 Nano",
+ "modelId": "gpt-4.1-nano-2025-04-14",
+ "modelUrl": "https://platform.openai.com/docs/models/gpt-4.1-nano",
+ "hasIcon": false,
+ "printed": 51.8564,
+ "handwritten": 28.3488,
+ "total": 39.1984
+ },
+ {
+ "modelName": "Skywork R1V3",
+ "modelId": "Skywork/Skywork-R1V3-38B",
+ "modelUrl": "https://huggingface.co/Skywork/Skywork-R1V3-38B",
+ "hasIcon": false,
+ "printed": 42.4283,
+ "handwritten": 25.619,
+ "total": 33.3771
+ },
+ {
+ "modelName": "InternVL 3.5 (30B-A3B)",
+ "modelId": "OpenGVLab/InternVL3_5-30B-A3B",
+ "modelUrl": "https://huggingface.co/OpenGVLab/InternVL3_5-30B-A3B",
+ "hasIcon": false,
+ "printed": 35.8059,
+ "handwritten": 26.3691,
+ "total": 30.7246
+ },
+ {
+ "modelName": "R",
+ "modelId": "YannQi/R-4B",
+ "modelUrl": "https://huggingface.co/YannQi/R-4B",
+ "hasIcon": false,
+ "printed": 32.6874,
+ "handwritten": 21.735,
+ "total": 26.79
+ },
+ {
+ "modelName": "GPT-5 Nano",
+ "modelId": "gpt-5-nano-2025-08-07",
+ "modelUrl": "https://platform.openai.com/docs/models/gpt-5-nano",
+ "hasIcon": false,
+ "printed": 38.7162,
+ "handwritten": 14.165,
+ "total": 25.4963
+ },
+ {
+ "modelName": "Nemotron Nano VL",
+ "modelId": "nvidia/Llama-3.1-Nemotron-Nano-VL-8B-V1",
+ "modelUrl": "https://huggingface.co/nvidia/Llama-3.1-Nemotron-Nano-VL-8B-V1",
+ "hasIcon": false,
+ "printed": 29.3383,
+ "handwritten": 15.2615,
+ "total": 21.7585
+ },
+ {
+ "modelName": "Phi 4 Multimodal",
+ "modelId": "microsoft/Phi-4-multimodal-instruct",
+ "modelUrl": "https://huggingface.co/microsoft/Phi-4-multimodal-instruct",
+ "hasIcon": false,
+ "printed": 9.0269,
+ "handwritten": 5.3393,
+ "total": 7.0413
+ }
+]
diff --git a/churro_transformers_infer.py b/churro_transformers_infer.py
deleted file mode 100644
index 14552f4..0000000
--- a/churro_transformers_infer.py
+++ /dev/null
@@ -1,192 +0,0 @@
-#!/usr/bin/env python3
-"""Run the fine-tuned Churro VLM on a single image using only Transformers and Pytorch.
-
-This script is a lightweight fallback for environments that cannot install the
-full Churro package. It loads the `stanford-oval/churro-3B` model from the
-Hugging Face Hub and transcribes a document page to XML.
-"""
-
-from __future__ import annotations
-
-import argparse
-from pathlib import Path
-from typing import Any
-
-from PIL import Image
-import torch
-from transformers import AutoModelForImageTextToText, AutoProcessor
-from transformers.image_utils import load_image
-
-
-DEFAULT_MODEL_ID = "stanford-oval/churro-3B"
-DEFAULT_SYSTEM_MESSAGE = "Transcribe the entiretly of this historical documents to XML format."
-MAX_IMAGE_DIM = 2500
-MIN_PIXELS = 512 * 28 * 28
-MAX_PIXELS = 5120 * 28 * 28
-
-
-def _parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(description="Standalone Churro OCR inference")
- parser.add_argument("image", type=Path, help="Path to the page image (PNG, JPG, or WebP)")
- parser.add_argument(
- "--model-id",
- default=DEFAULT_MODEL_ID,
- help="Hugging Face model ID to load (defaults to stanford-oval/churro-3B)",
- )
- parser.add_argument(
- "--system-message",
- default=DEFAULT_SYSTEM_MESSAGE,
- help="System prompt to prepend before presenting the image",
- )
- parser.add_argument(
- "--max-new-tokens",
- type=int,
- default=20_000,
- help="Maximum number of tokens to generate",
- )
- parser.add_argument(
- "--temperature",
- type=float,
- default=0.6,
- help="Sampling temperature",
- )
- parser.add_argument(
- "--device",
- default="auto",
- choices=["auto", "cpu", "cuda"],
- help="Computation device. 'auto' picks CUDA when available",
- )
- return parser.parse_args()
-
-
-def _resize_image_to_fit(image: Image.Image, max_width: int, max_height: int) -> Image.Image:
- """Match Churro's LLM preprocessing guard (<=2500px on the longest side)."""
- width, height = image.size
- if width <= max_width and height <= max_height:
- return image
-
- scale = min(max_width / width, max_height / height)
- new_size = (max(1, int(width * scale)), max(1, int(height * scale)))
- if hasattr(Image, "Resampling"):
- resample_filter = Image.Resampling.LANCZOS
- else: # pragma: no cover - Pillow < 10 fallback
- resample_filter = Image.LANCZOS # type: ignore[attr-defined]
- return image.resize(new_size, resample=resample_filter)
-
-
-def _load_processor(model_id: str) -> AutoProcessor:
- """Instantiate the processor with the same pixel bounds used during fine-tuning."""
- processor_kwargs: dict[str, Any] = {"trust_remote_code": True}
- return AutoProcessor.from_pretrained(
- model_id,
- min_pixels=MIN_PIXELS,
- max_pixels=MAX_PIXELS,
- **processor_kwargs,
- )
-
-
-def _select_device(preference: str) -> torch.device:
- if preference == "cpu":
- return torch.device("cpu")
- if preference == "cuda":
- if not torch.cuda.is_available():
- raise RuntimeError("CUDA requested but no GPU is available")
- return torch.device("cuda")
- return torch.device("cuda" if torch.cuda.is_available() else "cpu")
-
-
-def _prepare_inputs(
- processor: AutoProcessor,
- image_path: Path,
- system_message: str,
- device: torch.device,
-) -> dict[str, Any]:
- image = load_image(str(image_path))
- if not isinstance(image, Image.Image): # pragma: no cover - defensive
- raise TypeError(f"Unexpected image type: {type(image)!r}")
- image = image.convert("RGB")
- image = _resize_image_to_fit(image, MAX_IMAGE_DIM, MAX_IMAGE_DIM)
- conversation = [
- {"role": "system", "content": [{"type": "text", "text": system_message}]},
- {"role": "user", "content": [{"type": "image", "image": image}]},
- ]
- prompt = processor.apply_chat_template(
- conversation,
- tokenize=False,
- add_generation_prompt=True,
- )
- encoded = processor(
- text=[prompt],
- images=[image],
- return_tensors="pt",
- )
- encoded = {
- key: value.to(device) for key, value in encoded.items() if isinstance(value, torch.Tensor)
- }
- encoded["prompt_text"] = prompt
- encoded["conversation"] = conversation
- return encoded
-
-
-def _run_generation(
- model: AutoModelForImageTextToText,
- processor: AutoProcessor,
- inputs: dict[str, Any],
- max_new_tokens: int,
- temperature: float,
-) -> str:
- input_ids = inputs["input_ids"]
- input_length = input_ids.shape[1]
- generation_kwargs: dict[str, Any] = {
- "max_new_tokens": max_new_tokens,
- "do_sample": temperature > 0,
- }
- if temperature > 0:
- generation_kwargs["temperature"] = temperature
- if processor.tokenizer.pad_token_id is not None:
- generation_kwargs.setdefault("pad_token_id", processor.tokenizer.pad_token_id)
- if processor.tokenizer.eos_token_id is not None:
- generation_kwargs.setdefault("eos_token_id", processor.tokenizer.eos_token_id)
-
- with torch.inference_mode():
- generated = model.generate(
- **{k: v for k, v in inputs.items() if isinstance(v, torch.Tensor)}, **generation_kwargs
- )
-
- new_tokens = generated[0, input_length:]
- transcription = processor.tokenizer.decode(new_tokens, skip_special_tokens=True)
- return transcription.strip()
-
-
-def main() -> None:
- args = _parse_args()
- if not args.image.exists():
- raise FileNotFoundError(f"Image not found: {args.image}")
-
- device = _select_device(args.device)
- dtype = torch.bfloat16 if device.type == "cuda" else torch.float32
-
- processor = _load_processor(args.model_id)
- model = AutoModelForImageTextToText.from_pretrained(
- args.model_id,
- dtype=dtype,
- trust_remote_code=True,
- low_cpu_mem_usage=True,
- )
- model.to(device)
- model.eval()
-
- inputs = _prepare_inputs(processor, args.image, args.system_message, device)
- transcription = _run_generation(
- model,
- processor,
- inputs,
- max_new_tokens=args.max_new_tokens,
- temperature=args.temperature,
- )
-
- print(transcription)
-
-
-if __name__ == "__main__":
- main()
diff --git a/cli/__init__.py b/cli/__init__.py
deleted file mode 100644
index 37e6436..0000000
--- a/cli/__init__.py
+++ /dev/null
@@ -1,6 +0,0 @@
-"""Unified command-line interface for OCR workflows."""
-
-from .main import main
-
-
-__all__ = ["main"]
diff --git a/cli/__main__.py b/cli/__main__.py
deleted file mode 100644
index f55b584..0000000
--- a/cli/__main__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-from __future__ import annotations
-
-import sys
-
-from .main import main
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/cli/benchmark.py b/cli/benchmark.py
deleted file mode 100644
index 150ca3d..0000000
--- a/cli/benchmark.py
+++ /dev/null
@@ -1,105 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-from time import time
-
-from datasets import load_dataset
-
-from churro.args import CHURRO_DATASET_ID, create_output_prefix
-from churro.evaluation.metrics import compute_metrics
-from churro.systems.ocr_factory import OCRFactory
-from churro.utils.image.binarizer import ImageBinarizer
-from churro.utils.llm.models import MODEL_MAP
-from churro.utils.log_utils import logger
-
-from .helpers import managed_vllm_container
-
-
-VALID_DATASET_SPLITS = {"dev", "test"}
-
-
-@dataclass(slots=True)
-class BenchmarkOptions:
- system: str
- engine: str | None
- tensor_parallel_size: int
- data_parallel_size: int
- resize: int | None
- max_concurrency: int
- input_size: int
- dataset_split: str
- offset: int
- binarize: bool = False
-
-
-def _validate_options(options: BenchmarkOptions) -> int:
- if options.system == "llm":
- if not options.engine:
- logger.error("LLM engine must be specified for the LLM baseline.")
- return 1
- if options.engine not in MODEL_MAP:
- valid_engines = ", ".join(sorted(MODEL_MAP.keys()))
- logger.error(f"Invalid engine: {options.engine}. Possible values are: {valid_engines}")
- return 1
- if options.dataset_split not in VALID_DATASET_SPLITS:
- valid = ", ".join(sorted(VALID_DATASET_SPLITS))
- logger.error(f"Invalid dataset split '{options.dataset_split}'. Choose from: {valid}.")
- return 1
- return 0
-
-
-async def run(options: BenchmarkOptions) -> int:
- """Execute the benchmark workflow using the provided options."""
- validation_status = _validate_options(options)
- if validation_status != 0:
- return validation_status
-
- output_prefix = create_output_prefix(options) # type: ignore[arg-type]
-
- start_index = options.offset
- end_index = options.offset + options.input_size if options.input_size > 0 else None
-
- logger.info(
- f"Loading dataset slice: split={options.dataset_split}, offset={options.offset}, "
- f"limit={options.input_size if end_index is None else options.input_size}"
- )
- dataset = list(load_dataset(CHURRO_DATASET_ID, split=options.dataset_split, streaming=True))
- dataset = dataset[start_index:end_index]
-
- elapsed_time = 0.0
- binarizer: ImageBinarizer | None = None
- if options.binarize:
- logger.info("Binarizing dataset images prior to benchmarking.")
- try:
- binarizer = ImageBinarizer()
- except Exception as exc: # pragma: no cover - defensive guard
- logger.error(f"Failed to initialize image binarizer: {exc}")
- return 1
- with managed_vllm_container(
- engine=options.engine,
- backup_engine=None,
- system=options.system,
- tensor_parallel_size=options.tensor_parallel_size,
- data_parallel_size=options.data_parallel_size,
- ):
- ocr_system = OCRFactory.create_ocr_system(options) # type: ignore[arg-type]
- start_time = time()
- images = [example["image"] for example in dataset]
- if binarizer is not None:
- try:
- images = binarizer.binarize_pil_batch(images)
- except Exception as exc: # pragma: no cover - defensive guard
- logger.error(f"Binarizer batch inference failed: {exc}")
- return 1
- predicted_texts = await ocr_system.process_images(
- images,
- max_concurrency=options.max_concurrency,
- )
- elapsed_time = time() - start_time
-
- assert len(dataset) == len(predicted_texts), (
- f"Mismatch in number of examples ({len(dataset)}) and predicted texts ({len(predicted_texts)})."
- )
-
- compute_metrics(dataset, predicted_texts, output_prefix, elapsed_time)
- return 0
diff --git a/cli/docs_to_images.py b/cli/docs_to_images.py
deleted file mode 100644
index 9fc07b7..0000000
--- a/cli/docs_to_images.py
+++ /dev/null
@@ -1,180 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Sequence
-from dataclasses import dataclass
-from pathlib import Path
-
-import typer
-
-from churro.utils.concurrency import TqdmProgressReporter
-from churro.utils.llm import shutdown_llm_clients
-from churro.utils.llm.models import MODEL_MAP
-from churro.utils.log_utils import logger
-from churro.utils.pdf import run_pdf_pipeline
-from churro.utils.pdf.runner import (
- SUPPORTED_IMAGE_EXTENSIONS,
- default_splitter_factory,
- default_trimmer_factory,
-)
-
-
-DEFAULT_EXTENSIONS: tuple[str, ...] = (".pdf", ".jpg", ".jpeg", ".png", ".tiff", ".tif")
-DEFAULT_ENGINE = "gemini-2.5-pro-low"
-DEFAULT_PATTERN = "*"
-# Fallback DPI used when native page resolution cannot be determined.
-DEFAULT_DPI = 300
-DEFAULT_BATCH_PAGES = 16
-DEFAULT_QUEUE_MAXSIZE = 64
-DEFAULT_LLM_CONCURRENCY_LIMIT = 64
-
-
-@dataclass(slots=True)
-class DocsToImagesOptions:
- input_dir: Path | None
- input_file: Path | None
- recursive: bool
- pattern: str
- extensions: Sequence[str]
- output_dir: Path
- engine: str
- dpi: int | None
- batch_pages: int
- queue_maxsize: int
- raster_workers: int | None
- page_workers: int | None
- llm_concurrency_limit: int
- no_trim: bool
- dry_run: bool
-
-
-def _normalise_extensions(raw_exts: Sequence[str]) -> list[str]:
- cleaned: list[str] = []
- seen: set[str] = set()
- for ext in raw_exts:
- ext = ext.strip().lower()
- if not ext:
- continue
- if not ext.startswith("."):
- ext = f".{ext}"
- if ext in seen:
- continue
- seen.add(ext)
- cleaned.append(ext)
- return cleaned
-
-
-def _collect_inputs(
- input_dir: Path | None,
- input_file: Path | None,
- recursive: bool,
- pattern: str,
- extensions: list[str],
-) -> tuple[list[str], list[str]]:
- pdfs: list[str] = []
- images: list[str] = []
-
- def _add_path(path: Path) -> None:
- ext = path.suffix.lower()
- if ext == ".pdf":
- pdfs.append(str(path))
- elif ext in SUPPORTED_IMAGE_EXTENSIONS:
- images.append(str(path))
- else:
- logger.warning(f"Skipping unsupported extension '{ext}' for file {path}")
-
- if input_file:
- if input_file.is_file():
- if input_file.suffix.lower() in extensions:
- _add_path(input_file)
- else:
- logger.warning(
- f"--input-file {input_file} does not match the provided extensions; ignoring."
- )
- else:
- logger.warning(f"--input-file {input_file} is not a file; ignoring.")
-
- if input_dir:
- glob_pattern = f"**/{pattern}" if recursive else pattern
- for path in input_dir.glob(glob_pattern):
- if not path.is_file():
- continue
- if path.suffix.lower() not in extensions:
- continue
- _add_path(path)
-
- def _dedupe(values: list[str]) -> list[str]:
- seen: set[str] = set()
- ordered: list[str] = []
- for value in values:
- if value in seen:
- continue
- seen.add(value)
- ordered.append(value)
- return ordered
-
- return _dedupe(pdfs), _dedupe(images)
-
-
-def _validate_engine(engine: str) -> None:
- if engine not in MODEL_MAP:
- keys_preview = ", ".join(list(MODEL_MAP.keys())[:10])
- logger.error(
- f"Engine '{engine}' not found in MODEL_MAP. Available keys (first 10): {keys_preview} ..."
- )
- raise typer.Exit(code=2)
-
-
-async def run(options: DocsToImagesOptions) -> int:
- _validate_engine(options.engine)
-
- extensions = _normalise_extensions(options.extensions)
- if not extensions:
- logger.warning("No valid extensions provided.")
- return 0
-
- supported_extensions = [
- ext for ext in extensions if ext == ".pdf" or ext in SUPPORTED_IMAGE_EXTENSIONS
- ]
- if not supported_extensions:
- logger.warning("No supported extensions remain after filtering.")
- return 0
-
- pdf_files, image_files = _collect_inputs(
- input_dir=options.input_dir,
- input_file=options.input_file,
- recursive=options.recursive,
- pattern=options.pattern,
- extensions=supported_extensions,
- )
-
- if options.dry_run:
- for path in pdf_files + image_files:
- logger.info(f"DRY RUN: {path}")
- return 0
-
- if not pdf_files and not image_files:
- logger.warning("No valid PDF or image inputs provided to pipeline.")
- return 0
-
- progress = TqdmProgressReporter("docs-to-images")
- try:
- await run_pdf_pipeline(
- pdf_paths=pdf_files,
- output_dir=str(options.output_dir),
- engine=options.engine,
- dpi=options.dpi,
- batch_pages=options.batch_pages,
- queue_maxsize=options.queue_maxsize,
- raster_workers=options.raster_workers,
- page_workers=options.page_workers,
- llm_concurrency_limit=options.llm_concurrency_limit,
- trim=not options.no_trim,
- image_paths=image_files,
- splitter_factory=default_splitter_factory,
- trimmer_factory=default_trimmer_factory,
- progress_reporter=progress,
- )
- finally:
- progress.close()
- await shutdown_llm_clients()
- return 0
diff --git a/cli/helpers.py b/cli/helpers.py
deleted file mode 100644
index af42411..0000000
--- a/cli/helpers.py
+++ /dev/null
@@ -1,46 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Generator
-import contextlib
-from typing import Any
-
-from churro.utils.docker.vllm import maybe_start_vllm_server_for_engine
-from churro.utils.log_utils import logger
-
-
-@contextlib.contextmanager
-def managed_vllm_container(
- *,
- engine: str | None,
- backup_engine: str | None,
- system: str,
- tensor_parallel_size: int,
- data_parallel_size: int,
-) -> Generator[Any, None, None]:
- """Start a vLLM container when needed and ensure it stops on exit."""
- container = maybe_start_vllm_server_for_engine(
- engine=engine,
- system=system,
- tensor_parallel_size=tensor_parallel_size,
- data_parallel_size=data_parallel_size,
- )
- backup_container: Any | None = None
- if backup_engine and backup_engine != engine:
- backup_container = maybe_start_vllm_server_for_engine(
- engine=backup_engine,
- system=system,
- tensor_parallel_size=tensor_parallel_size,
- data_parallel_size=data_parallel_size,
- log_prefix="[backup]",
- )
- try:
- yield container
- finally:
- if backup_container is not None:
- logger.info("Stopping backup vLLM container...")
- with contextlib.suppress(Exception):
- backup_container.stop()
- if container is not None:
- logger.info("Stopping vLLM container...")
- with contextlib.suppress(Exception):
- container.stop()
diff --git a/cli/infer.py b/cli/infer.py
deleted file mode 100644
index 2459c8c..0000000
--- a/cli/infer.py
+++ /dev/null
@@ -1,320 +0,0 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
-import json
-from pathlib import Path
-import re
-
-from PIL import Image
-
-from churro.systems.llm_improver import LLMImprover
-from churro.systems.ocr_factory import OCRFactory
-from churro.utils.image.binarizer import ImageBinarizer
-from churro.utils.llm.models import MODEL_MAP
-from churro.utils.log_utils import logger
-
-from .helpers import managed_vllm_container
-
-
-ALLOWED_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
-SYSTEM_CHOICES = ["azure", "mistral_ocr", "llm", "finetuned"]
-DEFAULT_PATTERN = "*.png"
-DEFAULT_SUFFIXES: list[str] = [".png"]
-DEFAULT_MAX_CONCURRENCY = 64
-DEFAULT_IMPROVER_ENGINE = "gemini-2.5-pro-low"
-DEFAULT_IMPROVER_BACKUP_ENGINE = "gpt-5-low"
-
-
-@dataclass(slots=True)
-class InferOptions:
- system: str
- engine: str | None
- backup_engine: str | None
- tensor_parallel_size: int
- data_parallel_size: int
- image: Path | None
- image_dir: Path | None
- pattern: str
- suffixes: list[str]
- recursive: bool
- output_dir: Path | None
- skip_existing: bool
- max_concurrency: int
- strip_xml: bool = False
- use_improver: bool = False
- improver_engine: str | None = None
- improver_backup_engine: str | None = None
- improver_resize: int | None = None
- output_markdown: bool = False
- binarize: bool = False
-
-
-def _validate_options(options: InferOptions) -> int:
- systems_requiring_engine = {"llm", "finetuned"}
- valid_engines_string: str = json.dumps(sorted(MODEL_MAP.keys()), indent=2)
- if options.system in systems_requiring_engine:
- if not options.engine:
- logger.error(f"--engine is required when using system '{options.system}'.")
- return 1
- if options.engine not in MODEL_MAP:
- logger.error(
- f"Invalid --engine '{options.engine}'. Available options: {valid_engines_string}."
- )
- return 1
- elif options.engine and options.engine not in MODEL_MAP:
- logger.warning(
- f"--engine '{options.engine}' not found in MODEL_MAP; continuing without vLLM validation."
- )
- if options.backup_engine and options.backup_engine not in MODEL_MAP:
- logger.error(
- f"Invalid --backup-engine '{options.backup_engine}'. Available options: {valid_engines_string}."
- )
- return 1
- if options.strip_xml and options.system != "finetuned":
- logger.warning("--strip-xml only affects the 'finetuned' system.")
- if options.output_markdown and options.system != "llm":
- logger.error("--output-markdown is only supported when --system llm.")
- return 1
- improver_fields_provided = any(
- [
- options.improver_engine,
- options.improver_backup_engine,
- options.improver_resize is not None,
- ]
- )
- if options.use_improver:
- if not options.improver_engine:
- options.improver_engine = DEFAULT_IMPROVER_ENGINE
- logger.info(
- f"No --improver-engine provided; defaulting to '{DEFAULT_IMPROVER_ENGINE}'."
- )
- if options.improver_engine not in MODEL_MAP:
- logger.error(
- f"Invalid --improver-engine '{options.improver_engine}'. "
- f"Available options: {valid_engines_string}."
- )
- return 1
- if not options.improver_backup_engine:
- options.improver_backup_engine = DEFAULT_IMPROVER_BACKUP_ENGINE
- logger.info(
- f"No --improver-backup-engine provided; defaulting to '{DEFAULT_IMPROVER_BACKUP_ENGINE}'."
- )
- if options.improver_backup_engine and options.improver_backup_engine not in MODEL_MAP:
- logger.error(
- f"Invalid --improver-backup-engine '{options.improver_backup_engine}'. "
- f"Available options: {valid_engines_string}."
- )
- return 1
- elif improver_fields_provided:
- logger.warning(
- "Improver options provided without --use-improver; OCR outputs will not be post-processed."
- )
- invalid_suffixes = [s for s in options.suffixes if s not in ALLOWED_IMAGE_EXTS]
- if invalid_suffixes:
- preview = ", ".join(invalid_suffixes)
- logger.warning(f"Ignoring unsupported suffixes for infer command: {preview}")
- options.suffixes = [s for s in options.suffixes if s in ALLOWED_IMAGE_EXTS]
- if not options.suffixes:
- logger.error("No valid suffixes remain after filtering unsupported values.")
- return 1
- return 0
-
-
-def _collect_images(
- image: Path | None,
- image_dir: Path | None,
- suffixes: list[str],
- recursive: bool,
-) -> list[Path]:
- images: list[Path] = []
- if image and image.is_file():
- if image.suffix.lower() in ALLOWED_IMAGE_EXTS:
- images.append(image)
- else: # pragma: no cover - defensive
- logger.warning(f"--image {image} does not have a supported extension; skipping.")
- if image_dir and image_dir.is_dir():
- suffix_filter = {s.lower() for s in suffixes}
- iterator = image_dir.rglob("*") if recursive else image_dir.iterdir()
- for path in iterator:
- if not path.is_file():
- continue
- ext = path.suffix.lower()
- if ext in ALLOWED_IMAGE_EXTS and ext in suffix_filter:
- images.append(path)
- seen: set[str] = set()
- ordered: list[Path] = []
- for path in images:
- key = str(path)
- if key not in seen:
- seen.add(key)
- ordered.append(path)
- return ordered
-
-
-def _natural_key(path: Path) -> tuple[object, ...]:
- parts = re.split(r"(\d+)", path.name)
- return tuple(int(p) if p.isdigit() else p.lower() for p in parts)
-
-
-def _write_or_print_output(
- *,
- img_path: Path,
- text: str,
- output_dir: Path | None,
- skip_existing: bool,
- multi_mode: bool,
-) -> None:
- out_path: Path | None = None
- if output_dir:
- out_path = output_dir / (img_path.stem + ".txt")
- if skip_existing and out_path.exists():
- logger.info(f"Skipping existing {out_path.name}")
- return
- if out_path is not None:
- out_path.write_text(text)
- logger.info(f"Wrote {out_path}")
- else:
- if multi_mode:
- header = f"===== {img_path} ====="
- print(header)
- print(text)
-
-
-async def run(options: InferOptions) -> int:
- """Execute the ad-hoc inference workflow."""
- validation_status = _validate_options(options)
- if validation_status != 0:
- return validation_status
-
- if not options.image and not options.image_dir:
- logger.error("Either --image or --image-dir must be provided.")
- return 1
- if options.image and options.image_dir:
- logger.error("Specify only one of --image or --image-dir.")
- return 1
-
- images = _collect_images(
- options.image,
- options.image_dir,
- options.suffixes,
- options.recursive,
- )
- images = sorted(images, key=_natural_key)
- if not images:
- logger.error("No images found to process.")
- return 1
-
- original_image_paths = [str(path) for path in images]
- binarized_images: list[Image.Image] | None = None
- opened_originals: list[Image.Image] = []
- try:
- if options.binarize:
- logger.info(f"Binarizing {len(images)} input image(s) prior to OCR.")
- try:
- binarizer = ImageBinarizer()
- except Exception as exc: # pragma: no cover - defensive guard
- logger.error(f"Failed to initialize image binarizer: {exc}")
- return 1
- for img_path in images:
- try:
- opened_image = Image.open(img_path)
- except Exception as exc: # pragma: no cover - defensive guard
- logger.error(f"Failed to open {img_path} for binarization: {exc}")
- for image in opened_originals:
- image.close()
- return 1
- opened_originals.append(opened_image)
-
- try:
- binarized_images = binarizer.binarize_pil_batch(opened_originals)
- except Exception as exc: # pragma: no cover - defensive guard
- logger.error(f"Binarizer batch inference failed: {exc}")
- return 1
-
- if len(binarized_images) != len(images): # pragma: no cover - defensive guard
- logger.error(
- f"Binarizer returned {len(binarized_images)} outputs for {len(images)} inputs."
- )
- for image in binarized_images:
- image.close()
- return 1
-
- multi_mode = len(images) > 1
- if options.output_dir:
- options.output_dir.mkdir(parents=True, exist_ok=True)
-
- max_concurrency = options.max_concurrency if options.max_concurrency > 0 else 1
- if max_concurrency != options.max_concurrency:
- logger.warning("--max-concurrency < 1 ignored; defaulting to 1")
-
- with managed_vllm_container(
- engine=options.engine,
- backup_engine=options.backup_engine,
- system=options.system,
- tensor_parallel_size=options.tensor_parallel_size,
- data_parallel_size=options.data_parallel_size,
- ):
- ocr_system = OCRFactory.create_ocr_system(options) # type: ignore[arg-type]
- try:
- if binarized_images is not None:
- processed_outputs = await ocr_system.process_images(
- binarized_images,
- max_concurrency=max_concurrency,
- )
- else:
- processed_outputs = await ocr_system.process_images_from_files(
- original_image_paths,
- max_concurrency,
- )
- except RuntimeError as exc:
- logger.error(f"OCR processing failed: {exc}")
- return 1
- outputs: list[str] = processed_outputs
-
- if options.use_improver:
- improver_engine = options.improver_engine
- if not improver_engine:
- logger.error("Internal error: --use-improver enabled without --improver-engine.")
- return 1
- backup_suffix = (
- f" and backup engine '{options.improver_backup_engine}'"
- if options.improver_backup_engine
- else ""
- )
- logger.info(f"Running LLMImprover with engine '{improver_engine}'{backup_suffix}.")
- improver = LLMImprover(
- engine=improver_engine,
- backup_engine=options.improver_backup_engine,
- resize=options.improver_resize,
- image_fidelity="high",
- )
- outputs = await improver.process_batch_inputs(
- image_paths=original_image_paths,
- texts=outputs,
- max_concurrency=max_concurrency,
- )
-
- for img_path, text in zip(images, outputs, strict=False):
- _write_or_print_output(
- img_path=img_path,
- text=text,
- output_dir=options.output_dir,
- skip_existing=options.skip_existing,
- multi_mode=multi_mode,
- )
-
- if multi_mode:
- logger.info(f"Processed {len(images)} image(s).")
- return 0
- finally:
- if binarized_images is not None:
- for image in binarized_images:
- try:
- image.close()
- except Exception: # pragma: no cover - best effort cleanup
- pass
- for image in opened_originals:
- try:
- image.close()
- except Exception: # pragma: no cover - best effort cleanup
- pass
diff --git a/cli/main.py b/cli/main.py
deleted file mode 100644
index 85f7f8c..0000000
--- a/cli/main.py
+++ /dev/null
@@ -1,493 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-from collections.abc import Callable, Coroutine, Sequence
-from functools import wraps
-import os
-from pathlib import Path
-from typing import Any, ParamSpec, TypeVar
-
-import typer # type: ignore[import]
-
-from churro.systems.detect_layout import (
- log_total_azure_cost,
- log_total_google_document_ai_cost,
-)
-from churro.systems.ocr_factory import OCRFactory
-from churro.utils.llm import log_total_llm_cost
-from churro.utils.log_utils import logger
-
-from . import benchmark, docs_to_images, infer, text_to_historical_doc_xml
-
-
-os.environ.setdefault("TOKENIZERS_PARALLELISM", "false")
-
-
-app = typer.Typer(
- help="Churro OCR command-line interface",
-)
-
-
-_P = ParamSpec("_P")
-_T = TypeVar("_T")
-
-
-def _normalize_suffixes(suffixes: list[str] | None, *, default: Sequence[str]) -> list[str]:
- """Convert bare suffix tokens (e.g. 'pdf') into dotted extensions."""
- if not suffixes:
- return list(default)
- cleaned: list[str] = []
- seen: set[str] = set()
- for suffix in suffixes:
- token = suffix.strip().lower()
- if not token:
- continue
- if token.startswith("."):
- token = token[1:]
- ext = f".{token}"
- if ext in seen:
- continue
- seen.add(ext)
- cleaned.append(ext)
- return cleaned or list(default)
-
-
-def _synchronous(handler: Callable[_P, Coroutine[Any, Any, _T]]) -> Callable[_P, _T]:
- @wraps(handler)
- def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _T:
- try:
- return asyncio.run(handler(*args, **kwargs))
- except KeyboardInterrupt as err:
- logger.info("Interrupted by user")
- raise typer.Exit(code=130) from err
-
- return wrapper
-
-
-@app.command("docs-to-images")
-@_synchronous
-async def docs_to_images_command(
- input_dir: Path | None = typer.Option(
- None,
- "--input-dir",
- help="Directory containing input files.",
- exists=True,
- file_okay=False,
- dir_okay=True,
- readable=True,
- ),
- input_file: Path | None = typer.Option(
- None,
- "--input-file",
- help="Single file to process.",
- exists=True,
- file_okay=True,
- dir_okay=False,
- readable=True,
- ),
- recursive: bool = typer.Option(
- False,
- "--recursive",
- help="Recurse into subdirectories when using --input-dir.",
- ),
- suffix: list[str] | None = typer.Option(
- None,
- "--suffix",
- "-s",
- help="File suffix without dot (e.g. pdf, png). Repeat to add more.",
- ),
- output_dir: Path = typer.Option(
- ...,
- "--output-dir",
- help="Destination directory for output PNG files (created if missing).",
- file_okay=False,
- dir_okay=True,
- writable=True,
- ),
- engine: str = typer.Option(
- docs_to_images.DEFAULT_ENGINE,
- "--engine",
- help="Logical model key for page splitting (MODEL_MAP).",
- show_default=True,
- ),
- dpi: int | None = typer.Option(
- None,
- "--dpi",
- help="Rasterization DPI. Defaults to native page DPI when available.",
- show_default=False,
- ),
- batch_pages: int = typer.Option(
- docs_to_images.DEFAULT_BATCH_PAGES,
- "--batch-pages",
- help="Number of PDF pages per raster batch.",
- show_default=True,
- ),
- queue_maxsize: int = typer.Option(
- docs_to_images.DEFAULT_QUEUE_MAXSIZE,
- "--queue-maxsize",
- help="Maximum queue size for raster & processed queues.",
- show_default=True,
- ),
- raster_workers: int | None = typer.Option(
- None,
- "--raster-workers",
- help="Override number of raster process pool workers.",
- ),
- page_workers: int | None = typer.Option(
- None,
- "--page-workers",
- help="Override number of async page processing workers.",
- ),
- llm_concurrency_limit: int = typer.Option(
- docs_to_images.DEFAULT_LLM_CONCURRENCY_LIMIT,
- "--llm-concurrency-limit",
- help="Max simultaneous LLM calls for page splitting.",
- show_default=True,
- ),
- no_trim: bool = typer.Option(
- False,
- "--no-trim",
- help="Disable layout-based margin trimming.",
- ),
- dry_run: bool = typer.Option(
- False,
- "--dry-run",
- help="List the PDFs that would be processed then exit without running pipeline.",
- ),
-) -> int:
- if (input_dir is None) == (input_file is None):
- raise typer.BadParameter(
- "Provide exactly one of --input-dir or --input-file.",
- param_hint="--input-dir/--input-file",
- )
-
- options = docs_to_images.DocsToImagesOptions(
- input_dir=input_dir,
- input_file=input_file,
- recursive=recursive,
- pattern=docs_to_images.DEFAULT_PATTERN,
- extensions=_normalize_suffixes(suffix, default=docs_to_images.DEFAULT_EXTENSIONS),
- output_dir=output_dir,
- engine=engine,
- dpi=dpi,
- batch_pages=batch_pages,
- queue_maxsize=queue_maxsize,
- raster_workers=raster_workers,
- page_workers=page_workers,
- llm_concurrency_limit=llm_concurrency_limit,
- no_trim=no_trim,
- dry_run=dry_run,
- )
- result = await docs_to_images.run(options)
- log_total_llm_cost()
- log_total_azure_cost()
- log_total_google_document_ai_cost()
- if result != 0:
- raise typer.Exit(code=result)
- return result
-
-
-@app.command("infer")
-@_synchronous
-async def infer_command(
- system: str = typer.Option(
- ...,
- "--system",
- help=f"OCR system identifier. Choices: {', '.join(infer.SYSTEM_CHOICES)}.",
- ),
- engine: str | None = typer.Option(
- None,
- "--engine",
- help="Logical engine key (MODEL_MAP) when the system requires an LLM backend.",
- ),
- backup_engine: str | None = typer.Option(
- None,
- "--backup-engine",
- help="Optional backup engine key (MODEL_MAP) for the OCR system.",
- ),
- tensor_parallel_size: int = typer.Option(
- 1,
- "--tensor-parallel-size",
- help="(vLLM only) Tensor parallel size for launched container.",
- show_default=True,
- ),
- data_parallel_size: int = typer.Option(
- 1,
- "--data-parallel-size",
- help="(vLLM only) Data parallel size for launched container.",
- show_default=True,
- ),
- image: Path | None = typer.Option(
- None,
- "--image",
- help="Path to a page image. If you have a PDF, rasterize first.",
- exists=True,
- file_okay=True,
- dir_okay=False,
- readable=True,
- ),
- image_dir: Path | None = typer.Option(
- None,
- "--image-dir",
- help="Directory containing images to batch transcribe.",
- exists=True,
- file_okay=False,
- dir_okay=True,
- readable=True,
- ),
- suffix: list[str] | None = typer.Option(
- None,
- "--suffix",
- "-s",
- help="Image suffix without dot (e.g. png). Repeat to add more.",
- ),
- recursive: bool = typer.Option(
- False,
- "--recursive",
- help="Recurse into subdirectories when using --image-dir.",
- ),
- output_dir: Path | None = typer.Option(
- None,
- "--output-dir",
- help="Write transcriptions to this directory as .txt (defaults to --image-dir when omitted).",
- file_okay=False,
- dir_okay=True,
- writable=True,
- ),
- skip_existing: bool = typer.Option(
- False,
- "--skip-existing",
- help="Skip images that already have a corresponding output .txt in --output-dir.",
- ),
- max_concurrency: int = typer.Option(
- infer.DEFAULT_MAX_CONCURRENCY,
- "--max-concurrency",
- help="Max concurrent image requests to vLLM server.",
- show_default=True,
- ),
- binarize: bool = typer.Option(
- False,
- "--binarize",
- help="Binarize input images with a neural model before OCR.",
- ),
- strip_xml: bool = typer.Option(
- False,
- "--strip-xml",
- help="Finetuned system outputs HistoricalDocument XML by default. Set this flag to output plain text instead.",
- ),
- output_markdown: bool = typer.Option(
- False,
- "--output-markdown",
- help="Instruct the LLM to output Markdown instead of plain text (LLM system only).",
- ),
- use_improver: bool = typer.Option(
- False,
- "--use-improver",
- help="Post-process OCR text with the LLMImprover.",
- ),
- improver_engine: str = typer.Option(
- infer.DEFAULT_IMPROVER_ENGINE,
- "--improver-engine",
- help="Logical engine key (MODEL_MAP) for the LLM improver.",
- show_default=True,
- ),
- improver_backup_engine: str = typer.Option(
- infer.DEFAULT_IMPROVER_BACKUP_ENGINE,
- "--improver-backup-engine",
- help="Optional backup engine key for the LLM improver.",
- show_default=True,
- ),
- improver_resize: int | None = typer.Option(
- None,
- "--improver-resize",
- help="Resize longest image side to this many pixels before improvement.",
- ),
-) -> int:
- if image is None and image_dir is None:
- raise typer.BadParameter(
- "Provide either --image or --image-dir.",
- param_hint="--image/--image-dir",
- )
- if image and image_dir:
- raise typer.BadParameter(
- "Specify only one of --image or --image-dir.",
- param_hint="--image/--image-dir",
- )
-
- system_key = system.lower()
-
- resolved_output_dir = output_dir
- if resolved_output_dir is None and image_dir is not None:
- resolved_output_dir = image_dir
- logger.info(
- "No --output-dir provided; using --image-dir as output destination.",
- )
-
- options = infer.InferOptions(
- system=system_key,
- engine=engine,
- backup_engine=backup_engine,
- tensor_parallel_size=tensor_parallel_size,
- data_parallel_size=data_parallel_size,
- image=image,
- image_dir=image_dir,
- pattern=infer.DEFAULT_PATTERN,
- suffixes=_normalize_suffixes(suffix, default=infer.DEFAULT_SUFFIXES),
- recursive=recursive,
- output_dir=resolved_output_dir,
- skip_existing=skip_existing,
- max_concurrency=max_concurrency,
- strip_xml=strip_xml,
- output_markdown=output_markdown,
- use_improver=use_improver,
- improver_engine=improver_engine if use_improver else None,
- improver_backup_engine=improver_backup_engine if use_improver else None,
- improver_resize=improver_resize,
- binarize=binarize,
- )
- result = await infer.run(options)
- log_total_llm_cost()
- log_total_azure_cost()
- log_total_google_document_ai_cost()
- if result != 0:
- raise typer.Exit(code=result)
- return result
-
-
-@app.command("text-to-historical-doc-xml")
-@_synchronous
-async def text_to_historical_doc_xml_command(
- input_dir: Path = typer.Argument(
- ...,
- exists=True,
- file_okay=False,
- dir_okay=True,
- readable=True,
- help="Directory containing matched PNG/TXT pairs.",
- ),
- engine: str = typer.Option(
- text_to_historical_doc_xml.DEFAULT_ENGINE,
- "--engine",
- help="Logical model key to use for XML generation.",
- show_default=True,
- ),
- max_concurrency: int = typer.Option(
- text_to_historical_doc_xml.DEFAULT_MAX_CONCURRENCY,
- "--max-concurrency",
- help="Maximum number of concurrent LLM calls.",
- show_default=True,
- ),
- corpus_description: str = typer.Option(
- "",
- "--corpus-description",
- help="Optional corpus description to include in prompts.",
- ),
- overwrite: bool = typer.Option(
- False,
- "--overwrite",
- help="Regenerate XML even if output already exists.",
- ),
-) -> None:
- await text_to_historical_doc_xml.run_text_to_historical_doc_xml(
- input_dir,
- engine,
- max_concurrency,
- corpus_description,
- overwrite,
- )
- log_total_llm_cost()
-
-
-@app.command("benchmark")
-@_synchronous
-async def benchmark_command(
- system: str = typer.Option(
- ...,
- "--system",
- help=f"Specify the system to run. Choices: {', '.join(OCRFactory.get_available_systems())}.",
- ),
- engine: str | None = typer.Option(
- None,
- "--engine",
- help="For LLM baseline, specify the LLM to use.",
- ),
- tensor_parallel_size: int = typer.Option(
- 1,
- "--tensor-parallel-size",
- help="Tensor parallel size. Only used for local models.",
- show_default=True,
- ),
- data_parallel_size: int = typer.Option(
- 1,
- "--data-parallel-size",
- help="Data parallel size. Only used for local models.",
- show_default=True,
- ),
- resize: int | None = typer.Option(
- None,
- "--resize",
- help="If set, resize large images to fit inside a square of this size (in pixels).",
- ),
- binarize: bool = typer.Option(
- False,
- "--binarize",
- help="Binarize dataset images with the ONNX model before benchmarking.",
- ),
- max_concurrency: int = typer.Option(
- 50,
- "--max-concurrency",
- help="Maximum number of LLM requests to allow at once.",
- show_default=True,
- ),
- input_size: int = typer.Option(
- 0,
- "--input-size",
- help="Number of images to process. 0 means all images.",
- show_default=True,
- ),
- dataset_split: str = typer.Option(
- ...,
- "--dataset-split",
- help="Data split to use (dev or test).",
- ),
- offset: int = typer.Option(
- 0,
- "--offset",
- help="Offset for the input images.",
- show_default=True,
- ),
-) -> int:
- system_key = system.lower()
- dataset_split_value = dataset_split.lower()
-
- options = benchmark.BenchmarkOptions(
- system=system_key,
- engine=engine,
- tensor_parallel_size=tensor_parallel_size,
- data_parallel_size=data_parallel_size,
- resize=resize,
- max_concurrency=max_concurrency,
- input_size=input_size,
- dataset_split=dataset_split_value,
- offset=offset,
- binarize=binarize,
- )
- result = await benchmark.run(options)
- log_total_llm_cost()
- log_total_azure_cost()
- log_total_google_document_ai_cost()
- if result != 0:
- raise typer.Exit(code=result)
- return result
-
-
-def main(argv: Sequence[str] | None = None) -> int:
- try:
- app(args=list(argv) if argv is not None else None, standalone_mode=False)
- return 0
- except SystemExit as exc:
- return int(exc.code or 0)
-
-
-if __name__ == "__main__": # pragma: no cover
- app()
diff --git a/cli/text_to_historical_doc_xml.py b/cli/text_to_historical_doc_xml.py
deleted file mode 100644
index db64c40..0000000
--- a/cli/text_to_historical_doc_xml.py
+++ /dev/null
@@ -1,319 +0,0 @@
-from __future__ import annotations
-
-import argparse
-import asyncio
-from dataclasses import dataclass
-from functools import partial
-from pathlib import Path
-import re
-
-from lxml import etree # type: ignore[import]
-import xmlschema # type: ignore[import]
-
-from churro.utils.concurrency import run_async_in_parallel
-from churro.utils.image.io import load_image_async
-from churro.utils.llm import log_total_llm_cost, run_llm_async
-from churro.utils.log_utils import logger
-
-
-SCHEMA_PATH = Path(__file__).resolve().parent.parent / "evaluation" / "historical_doc.xsd"
-_SCHEMA: xmlschema.XMLSchema | None = None
-_SCHEMA_TEXT: str | None = None
-
-DEFAULT_ENGINE = "gemini-2.5-pro-medium"
-DEFAULT_MAX_CONCURRENCY = 64
-
-
-@dataclass(frozen=True)
-class DocumentExample:
- """Container for paired image/text inputs and the target XML output path."""
-
- stem: str
- image_path: Path
- text_path: Path
- xml_path: Path
-
-
-def _load_schema() -> xmlschema.XMLSchema:
- global _SCHEMA
- if _SCHEMA is None:
- _SCHEMA = xmlschema.XMLSchema(str(SCHEMA_PATH))
- return _SCHEMA
-
-
-def get_historical_doc_xml_schema() -> str:
- global _SCHEMA_TEXT
- if _SCHEMA_TEXT is None:
- _SCHEMA_TEXT = SCHEMA_PATH.read_text(encoding="utf-8")
- return _SCHEMA_TEXT
-
-
-def get_historical_doc_xml_errors(xml: str) -> str | None:
- try:
- parser = etree.XMLParser(remove_blank_text=True)
- root = etree.fromstring(xml.encode("utf-8"), parser=parser)
- except etree.XMLSyntaxError as exc: # XML is malformed before schema validation
- return f"XMLSyntaxError: {exc}"
-
- schema = _load_schema()
- if schema.is_valid(root):
- return None
-
- try:
- schema.validate(root)
- except xmlschema.validators.exceptions.XMLSchemaValidationError as exc:
- return str(exc)
- except Exception as exc: # Defensive guard for unexpected validation issues
- return f"ValidationError: {exc}"
-
- return "Unknown validation failure"
-
-
-def normalize_xml_string(xml: str) -> str:
- xml = xml.strip()
- if not xml:
- return xml
- try:
- parser = etree.XMLParser(remove_blank_text=True)
- root = etree.fromstring(xml.encode("utf-8"), parser=parser)
- return etree.tostring(root, encoding="unicode")
- except etree.XMLSyntaxError:
- return xml
-
-
-def prettify_xml(xml: str) -> str:
- """Return formatted XML with consistent indentation."""
- try:
- parser = etree.XMLParser(remove_blank_text=True)
- root = etree.fromstring(xml.encode("utf-8"), parser=parser)
- return etree.tostring(root, encoding="unicode", pretty_print=True)
- except etree.XMLSyntaxError:
- return xml
-
-
-def strip_xml_tag(xml: str) -> str:
- if "```xml" in xml:
- last_xml = xml.rfind("```xml")
- end_xml = xml.find("```", last_xml + 1)
- if end_xml == -1:
- end_xml = len(xml)
- xml = xml[last_xml + 6 : end_xml].strip()
-
- xml = re.sub(r"", "", xml, flags=re.DOTALL).strip()
- xml = xml.replace(
- "",
- '',
- )
- return normalize_xml_string(xml)
-
-
-async def llm_fix_xml_syntax(xml: str, example: DocumentExample, engine: str) -> str:
- xml_errors = get_historical_doc_xml_errors(xml)
- if xml_errors is None:
- return xml
-
- logger.info(
- f"XML errors found for '{example.xml_path}': {xml_errors}. Fixing with another LLM call."
- )
- user_message = (
- f"You will be given an invalid XML with the following error: {xml_errors}.\n"
- "Minimally modify the XML so that it matches the provided schema while preserving the original content.\n\n"
- f"Schema:\n{get_historical_doc_xml_schema()}\n"
- f"Invalid XML:\n{xml}"
- )
-
- corrected_xml = await run_llm_async(
- model=engine,
- system_prompt_text="Output the entire fixed XML, do not output any other text.",
- user_message_text=user_message,
- user_message_image=None,
- )
- return strip_xml_tag(corrected_xml)
-
-
-async def llm_transcribe(
- example: DocumentExample,
- engine: str,
- corpus_description: str,
-) -> str:
- ocr_text = example.text_path.read_text(encoding="utf-8")
- if not ocr_text.strip():
- logger.warning(f"OCR text is empty in {example.text_path.name}")
-
- schema_text = get_historical_doc_xml_schema()
- corpus_line = (
- f"The document belongs to the corpus {corpus_description}.\n\n"
- if corpus_description
- else ""
- )
-
- with await load_image_async(example.image_path) as image:
- llm_output = await run_llm_async(
- model=engine,
- system_prompt_text=(
- "You are an expert XML generator. Output a single, valid XML document that adheres to "
- "the HistoricalDocument schema. Do not output explanations or markdown fences.\n\n"
- f"XSD Schema:\n{schema_text}"
- ),
- user_message_text=(
- "You are provided with a scanned historical document image and an OCR transcription.\n"
- "Produce a diplomatic transcription that preserves original spellings, punctuation, "
- "capitalization, and abbreviations. Correct OCR errors using the image as reference, "
- "add any missing but legible text, and follow the reading order of the document.\n\n"
- f"{corpus_line}"
- f"OCR Transcription:\n{ocr_text}\n\n"
- "Return the complete XML string that validates against the schema."
- ),
- user_message_image=image,
- )
-
- xml_content = strip_xml_tag(llm_output)
- if not xml_content:
- logger.warning(
- f"Output for {example.image_path} is empty after stripping formatting. Original LLM output: {llm_output}"
- )
- return ""
-
- xml_content = await llm_fix_xml_syntax(xml_content, example, engine)
-
- if not xml_content:
- logger.error(f"Failed to obtain valid XML for {example.stem} after LLM retries.")
- return ""
-
- if get_historical_doc_xml_errors(xml_content):
- logger.error(f"Final XML for {example.stem} still fails schema validation after retries.")
- return ""
-
- example.xml_path.write_text(prettify_xml(xml_content), encoding="utf-8")
- return xml_content
-
-
-def collect_document_examples(input_dir: Path) -> list[DocumentExample]:
- if not input_dir.exists() or not input_dir.is_dir():
- raise FileNotFoundError(f"Input directory does not exist: {input_dir}")
-
- buckets: dict[str, dict[str, Path]] = {}
- for path in input_dir.iterdir():
- if not path.is_file():
- continue
- suffix = path.suffix.lower()
- if suffix not in {".png", ".txt"}:
- continue
- bucket = buckets.setdefault(path.stem, {})
- if suffix == ".png":
- bucket["image"] = path
- else:
- bucket["text"] = path
-
- examples: list[DocumentExample] = []
- missing_components: list[str] = []
-
- for stem, files in sorted(buckets.items()):
- image_path = files.get("image")
- text_path = files.get("text")
- if image_path and text_path:
- xml_path = image_path.with_suffix(".xml")
- examples.append(DocumentExample(stem, image_path, text_path, xml_path))
- else:
- missing_components.append(stem)
-
- if missing_components:
- logger.warning(
- f"Skipping {len(missing_components)} unmatched file pair(s): {', '.join(missing_components)}"
- )
-
- logger.info(f"Found {len(examples)} complete PNG/TXT pair(s) in {input_dir}")
- return examples
-
-
-async def process_examples(
- examples: list[DocumentExample],
- engine: str,
- max_concurrency: int,
- corpus_description: str,
-) -> None:
- if not examples:
- logger.warning("No document pairs found to process.")
- return
-
- results = await run_async_in_parallel(
- partial(llm_transcribe, engine=engine, corpus_description=corpus_description),
- examples,
- max_concurrency=max_concurrency,
- desc="Generating XML",
- )
-
- success_count = sum(1 for result in results if isinstance(result, str) and result)
- logger.info(f"Successfully generated XML for {success_count}/{len(examples)} document(s)")
-
-
-async def run_text_to_historical_doc_xml(
- input_dir: Path,
- engine: str,
- max_concurrency: int,
- corpus_description: str,
- overwrite: bool,
-) -> None:
- examples = collect_document_examples(input_dir)
-
- if not overwrite:
- before = len(examples)
- examples = [example for example in examples if not example.xml_path.exists()]
- skipped = before - len(examples)
- if skipped:
- logger.info("Skipping %d existing XML file(s). Use --overwrite to regenerate.", skipped)
-
- await process_examples(examples, engine, max_concurrency, corpus_description)
-
-
-def parse_args() -> argparse.Namespace:
- parser = argparse.ArgumentParser(
- description="Generate HistoricalDocument XML for PNG/TXT pairs."
- )
- parser.add_argument(
- "--input-dir",
- required=True,
- help="Directory containing paired X.png and X.txt files.",
- )
- parser.add_argument(
- "--engine",
- default=DEFAULT_ENGINE,
- help="Logical model key to use for LLM transcription.",
- )
- parser.add_argument(
- "--max-concurrency",
- type=int,
- default=DEFAULT_MAX_CONCURRENCY,
- help="Maximum number of concurrent LLM calls.",
- )
- parser.add_argument(
- "--corpus-description",
- default="",
- help="Optional free-form description of the corpus."
- " Included in the LLM prompt for additional context.",
- )
- parser.add_argument(
- "--overwrite",
- action="store_true",
- help="Regenerate XML even when an output file already exists.",
- )
- return parser.parse_args()
-
-
-def main() -> None:
- args = parse_args()
- input_dir = Path(args.input_dir)
- asyncio.run(
- run_text_to_historical_doc_xml(
- input_dir,
- args.engine,
- args.max_concurrency,
- args.corpus_description,
- args.overwrite,
- )
- )
- log_total_llm_cost()
-
-
-if __name__ == "__main__":
- main()
diff --git a/config/__init__.py b/config/__init__.py
deleted file mode 100644
index dca69f8..0000000
--- a/config/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-"""Configuration helpers for Churro.
-
-Expose `get_settings` as the canonical accessor for environment-driven
-configuration. Modules should avoid loading `.env` directly and instead
-import from this package to retrieve typed snapshots.
-"""
-
-from .settings import ChurroSettings, get_settings
-
-
-__all__ = ["ChurroSettings", "get_settings"]
diff --git a/config/settings.py b/config/settings.py
deleted file mode 100644
index ac23d6d..0000000
--- a/config/settings.py
+++ /dev/null
@@ -1,155 +0,0 @@
-"""Centralised environment configuration for Churro.
-
-This module ensures `.env` loading happens in one place and exposes a
-typed snapshot of provider credentials, runtime ports, and tuning knobs.
-Downstream modules call `get_settings()` instead of touching `os.environ`
-directly, making it easier to validate values and override behaviour in
-tests.
-"""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from functools import lru_cache
-import os
-from pathlib import Path
-
-from dotenv import load_dotenv
-
-
-_DEFAULT_ENV_PATH = Path(__file__).resolve().parents[1] / ".env"
-
-
-def _coerce_int(value: str | None) -> int | None:
- if value is None or value == "":
- return None
- try:
- return int(value)
- except ValueError:
- return None
-
-
-def _coerce_float(value: str | None) -> float | None:
- if value is None or value == "":
- return None
- try:
- return float(value)
- except ValueError:
- return None
-
-
-@dataclass(frozen=True)
-class AzureOpenAISettings:
- api_base: str | None
- api_version: str | None
- api_key: str | None
-
-
-@dataclass(frozen=True)
-class AzureDocumentIntelligenceSettings:
- endpoint: str | None
- api_key: str | None
-
-
-@dataclass(frozen=True)
-class VertexAISettings:
- project_id: str | None
- location: str
- document_ai_location: str
- ocr_processor_id: str | None
- ocr_processor_version: str | None
-
-
-@dataclass(frozen=True)
-class LocalRuntimeSettings:
- vllm_port: int | None
- huggingface_token: str | None
-
-
-@dataclass(frozen=True)
-class TokenSettings:
- openai: str | None
- mistral: str | None
-
-
-@dataclass(frozen=True)
-class ChurroSettings:
- """Top-level snapshot of configuration values."""
-
- env_file: Path
- azure_openai: AzureOpenAISettings
- azure_document_intelligence: AzureDocumentIntelligenceSettings
- vertex_ai: VertexAISettings
- google_cloud_project: str | None
- local: LocalRuntimeSettings
- tokens: TokenSettings
-
-
-def _resolve_env_path(env_file: os.PathLike[str] | str | None) -> Path:
- if env_file is None:
- return _DEFAULT_ENV_PATH
- return Path(env_file).resolve()
-
-
-@lru_cache(maxsize=4)
-def _load_settings(env_path: Path) -> ChurroSettings:
- # Load the environment file once per unique path. We avoid override=True so
- # that existing environment variables take precedence over `.env` defaults.
- load_dotenv(dotenv_path=env_path, override=False)
-
- azure_openai = AzureOpenAISettings(
- api_base=os.getenv("AZURE_API_BASE"),
- api_version=os.getenv("AZURE_API_VERSION"),
- api_key=os.getenv("AZURE_OPENAI_API_KEY") or os.getenv("OPENAI_API_KEY"),
- )
- azure_di = AzureDocumentIntelligenceSettings(
- endpoint=os.getenv("AZURE_DI_ENDPOINT"),
- api_key=os.getenv("AZURE_DOC_KEY"),
- )
-
- combined_project_id = os.getenv("VERTEX_AI_PROJECT_ID") or os.getenv("GOOGLE_CLOUD_PROJECT")
- vertex_ai = VertexAISettings(
- project_id=combined_project_id,
- location=os.getenv("VERTEX_AI_LOCATION", "us-east5"),
- document_ai_location=os.getenv("DOCUMENT_VERTEX_AI_LOCATION", "us"),
- ocr_processor_id=os.getenv("VERTEX_AI_OCR_PROCESSOR_ID"),
- ocr_processor_version=os.getenv("VERTEX_AI_OCR_PROCESSOR_VERSION"),
- )
-
- local_runtime = LocalRuntimeSettings(
- vllm_port=_coerce_int(os.getenv("LOCAL_VLLM_PORT") or "9000"),
- huggingface_token=os.getenv("HF_TOKEN"),
- )
-
- tokens = TokenSettings(
- openai=os.getenv("OPENAI_API_KEY"),
- mistral=os.getenv("MISTRAL_API_KEY"),
- )
-
- return ChurroSettings(
- env_file=env_path,
- azure_openai=azure_openai,
- azure_document_intelligence=azure_di,
- vertex_ai=vertex_ai,
- google_cloud_project=combined_project_id,
- local=local_runtime,
- tokens=tokens,
- )
-
-
-def get_settings(
- env_file: os.PathLike[str] | str | None = None,
- *,
- reload: bool = False,
-) -> ChurroSettings:
- """Return the cached settings snapshot.
-
- Args:
- env_file: Optional explicit path to a `.env` file. When omitted the repo
- root `.env` file is used.
- reload: When True the cached snapshot is cleared before loading.
- """
- env_path = _resolve_env_path(env_file)
- if reload:
- _load_settings.cache_clear()
- return _load_settings(env_path)
diff --git a/conftest.py b/conftest.py
deleted file mode 100644
index eb29332..0000000
--- a/conftest.py
+++ /dev/null
@@ -1,70 +0,0 @@
-# Test configuration utilities.
-# Ensures the repository root is on sys.path so that 'churro.utils', etc. can be imported
-# when running pytest without installing the package.
-from __future__ import annotations
-
-import asyncio
-from collections.abc import Generator
-import gc
-from pathlib import Path
-import sys
-
-import pytest
-
-from churro.utils.llm.shutdown import shutdown_llm_clients
-
-
-ROOT = Path(__file__).parent.resolve()
-if str(ROOT) not in sys.path:
- sys.path.insert(0, str(ROOT))
-
-
-@pytest.fixture(scope="session", autouse=True)
-def cleanup_async_clients() -> Generator[None, None, None]:
- """Ensure aiohttp sessions opened via litellm are closed after tests."""
- yield
-
- async def _close_async_resources() -> None:
- await shutdown_llm_clients()
-
- try:
- asyncio.run(_close_async_resources())
- except RuntimeError:
- loop = asyncio.new_event_loop()
- try:
- loop.run_until_complete(_close_async_resources())
- finally:
- loop.run_until_complete(loop.shutdown_asyncgens())
- loop.close()
-
- policy = asyncio.get_event_loop_policy()
- try:
- loop = policy.get_event_loop()
- except RuntimeError:
- loop = None
- if loop and not loop.is_closed():
- loop.run_until_complete(loop.shutdown_asyncgens())
- loop.close()
-
- for obj in gc.get_objects():
- if isinstance(obj, asyncio.AbstractEventLoop) and not obj.is_closed():
- try:
- obj.run_until_complete(obj.shutdown_asyncgens())
- except Exception:
- pass
- try:
- obj.close()
- except Exception:
- pass
-
-
-@pytest.fixture
-def event_loop() -> Generator[asyncio.AbstractEventLoop, None, None]:
- """Provide a fresh event loop per test and ensure it closes cleanly."""
- loop = asyncio.new_event_loop()
- try:
- yield loop
- finally:
- if not loop.is_closed():
- loop.run_until_complete(loop.shutdown_asyncgens())
- loop.close()
diff --git a/docs/_static/css/custom.css b/docs/_static/css/custom.css
new file mode 100644
index 0000000..b750fbf
--- /dev/null
+++ b/docs/_static/css/custom.css
@@ -0,0 +1,227 @@
+:root {
+ --pst-color-primary: #8b451f;
+ --pst-color-secondary: #a16207;
+ --pst-color-accent: #8b451f;
+ --pst-color-link: #8b451f;
+ --pst-color-link-hover: #6c2e10;
+ --pst-font-family-base: "Source Sans 3", "IBM Plex Sans", "Aptos", "Segoe UI", sans-serif;
+ --pst-font-family-heading: "Source Sans 3", "IBM Plex Sans", "Aptos", "Segoe UI", sans-serif;
+ --pst-font-family-monospace: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
+ --bs-body-font-size: 1.0625rem;
+ --bs-body-line-height: 1.65;
+}
+
+html[data-theme="dark"] {
+ --pst-color-primary: #f4b25e;
+ --pst-color-secondary: #d6a255;
+ --pst-color-accent: #f4b25e;
+ --pst-color-link: #f4b25e;
+ --pst-color-link-hover: #f8cf92;
+}
+
+.bd-article-container h1,
+.bd-article-container h2,
+.bd-article-container h3,
+.bd-article-container h4,
+.bd-article-container h5,
+.bd-article-container h6 {
+ font-family: var(--pst-font-family-heading);
+ font-weight: 600;
+ letter-spacing: 0;
+}
+
+.bd-article-container {
+ max-width: 58rem;
+}
+
+.bd-article-container h1 {
+ font-size: clamp(2.1rem, 2.4vw, 2.7rem);
+}
+
+.bd-article-container h2 {
+ font-size: clamp(1.65rem, 1.8vw, 2rem);
+}
+
+.bd-article-container h3 {
+ font-size: clamp(1.3rem, 1.35vw, 1.55rem);
+}
+
+.bd-header .navbar-header-items {
+ column-gap: 0.5rem;
+}
+
+.bd-docs-nav .bd-toc-item > ul + ul {
+ border-top: 1px solid var(--pst-color-border);
+ margin-top: 0.9rem;
+ padding-top: 0.9rem;
+}
+
+.bd-header .navbar-header-items__start,
+.bd-header .navbar-brand {
+ display: none;
+}
+
+.bd-header .navbar-header-items__end {
+ align-items: center;
+ display: flex;
+ justify-content: center;
+ width: 100%;
+}
+
+.bd-search .form-control {
+ min-width: min(100%, 40rem);
+}
+
+.bd-sidebar-primary #pst-collapse-sidebar-button {
+ gap: 0;
+ justify-content: center;
+ width: 1.75rem;
+}
+
+.bd-sidebar-primary #pst-collapse-sidebar-button .pst-icon {
+ padding: 0;
+}
+
+.prev-next-footer {
+ padding-top: 1.75rem;
+}
+
+.benchmark-table-wrapper {
+ overflow-x: auto;
+}
+
+.benchmark-table {
+ border-collapse: collapse;
+ min-width: 100%;
+ width: 100%;
+}
+
+.benchmark-table th,
+.benchmark-table td {
+ border-bottom: 1px solid rgba(139, 69, 31, 0.1);
+ padding: 0.9rem 0.85rem;
+ vertical-align: top;
+}
+
+.benchmark-table th {
+ background: rgba(139, 69, 31, 0.035);
+ font-size: 0.9rem;
+ font-weight: 600;
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.benchmark-sort-button {
+ align-items: center;
+ appearance: none;
+ background: transparent;
+ border: 0;
+ color: inherit;
+ display: inline-flex;
+ font: inherit;
+ gap: 0.5rem;
+ padding: 0;
+}
+
+.benchmark-sort-button:hover {
+ color: var(--pst-color-link-hover);
+}
+
+.benchmark-sort-indicator {
+ color: var(--pst-color-text-muted);
+ font-size: 0.8rem;
+}
+
+.benchmark-rank,
+.benchmark-score {
+ text-align: right;
+ white-space: nowrap;
+}
+
+.benchmark-rank {
+ color: var(--pst-color-text-muted);
+ width: 1%;
+}
+
+.benchmark-model {
+ align-items: start;
+ display: flex;
+ gap: 0.8rem;
+}
+
+.benchmark-model-icon {
+ border-radius: 0.4rem;
+ height: 1.65rem;
+ margin-top: 0.1rem;
+ width: 1.65rem;
+}
+
+.benchmark-model-text {
+ display: grid;
+ gap: 0.25rem;
+}
+
+.benchmark-model-name {
+ color: inherit;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.benchmark-model-name:hover {
+ color: var(--pst-color-link-hover);
+}
+
+.benchmark-model-id {
+ color: var(--pst-color-text-muted);
+ font-size: 0.83rem;
+ overflow-wrap: anywhere;
+}
+
+.benchmark-error {
+ color: var(--pst-color-danger);
+}
+
+html[data-theme="dark"] .benchmark-table th {
+ border-color: rgba(244, 178, 94, 0.18);
+}
+
+html[data-theme="dark"] .benchmark-table th {
+ background: rgba(244, 178, 94, 0.06);
+}
+
+@media (min-width: 1200px) {
+ .bd-page-width {
+ max-width: 96rem;
+ }
+
+ .bd-main .bd-content .bd-article-container {
+ max-width: 60rem;
+ }
+}
+
+@media (min-width: 960px) {
+ .bd-header .navbar-header-items {
+ display: flex;
+ flex: 1 1 auto;
+ justify-content: center;
+ margin-inline: auto;
+ padding-left: 0;
+ }
+
+ .bd-header .navbar-header-items__end {
+ column-gap: 0.75rem;
+ justify-content: center;
+ margin-inline: auto;
+ }
+
+ .bd-header .navbar-item.navbar-persistent--container,
+ .bd-header .search-button-field {
+ width: clamp(16rem, 28vw, 24rem);
+ }
+
+ .bd-header .search-button-field {
+ justify-content: space-between;
+ padding-inline: 0.9rem;
+ }
+}
diff --git a/docs/_static/img/churro.png b/docs/_static/img/churro.png
new file mode 100644
index 0000000..45d04c4
Binary files /dev/null and b/docs/_static/img/churro.png differ
diff --git a/docs/_static/js/benchmark-leaderboard.js b/docs/_static/js/benchmark-leaderboard.js
new file mode 100644
index 0000000..f0beee2
--- /dev/null
+++ b/docs/_static/js/benchmark-leaderboard.js
@@ -0,0 +1,215 @@
+(function () {
+ const SCORE_FORMATTER = new Intl.NumberFormat(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ });
+
+ 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 },
+ ];
+
+ function contentRoot() {
+ return document.documentElement.dataset.content_root || "";
+ }
+
+ function resolvePath(path) {
+ if (!path) {
+ return "";
+ }
+ if (/^(?:[a-z]+:)?\/\//i.test(path)) {
+ return path;
+ }
+ return `${contentRoot()}${path}`;
+ }
+
+ function formatScore(value) {
+ if (typeof value !== "number" || Number.isNaN(value)) {
+ return "—";
+ }
+ return SCORE_FORMATTER.format(value);
+ }
+
+ function compareRows(left, right, sortState) {
+ const { key, direction } = sortState;
+ const multiplier = direction === "asc" ? 1 : -1;
+ const leftValue = left[key];
+ const rightValue = right[key];
+
+ if (typeof leftValue === "number" && typeof rightValue === "number") {
+ if (leftValue !== rightValue) {
+ return (leftValue - rightValue) * multiplier;
+ }
+ return left.modelName.localeCompare(right.modelName);
+ }
+
+ return String(leftValue || "").localeCompare(String(rightValue || "")) * multiplier;
+ }
+
+ function createModelCell(row, logoPath) {
+ const wrapper = document.createElement("div");
+ wrapper.className = "benchmark-model";
+
+ if (row.hasIcon) {
+ const icon = document.createElement("img");
+ icon.className = "benchmark-model-icon";
+ icon.alt = `${row.modelName} icon`;
+ icon.src = logoPath;
+ wrapper.append(icon);
+ }
+
+ const textBlock = document.createElement("div");
+ textBlock.className = "benchmark-model-text";
+
+ const nameNode = row.modelUrl ? document.createElement("a") : document.createElement("span");
+ nameNode.className = "benchmark-model-name";
+ nameNode.textContent = row.modelName;
+ if (row.modelUrl) {
+ nameNode.href = row.modelUrl;
+ nameNode.target = "_blank";
+ nameNode.rel = "noreferrer noopener";
+ }
+
+ textBlock.append(nameNode);
+
+ if (row.modelId) {
+ const metaNode = document.createElement("code");
+ metaNode.className = "benchmark-model-id";
+ metaNode.textContent = row.modelId;
+ textBlock.append(metaNode);
+ }
+
+ wrapper.append(textBlock);
+ return wrapper;
+ }
+
+ function createHeaderButton(column, sortState, onSort) {
+ const button = document.createElement("button");
+ button.className = "benchmark-sort-button";
+ button.type = "button";
+ button.textContent = column.label;
+ button.dataset.sortKey = column.key;
+
+ const indicator = document.createElement("span");
+ indicator.className = "benchmark-sort-indicator";
+ if (sortState.key === column.key) {
+ indicator.textContent = sortState.direction === "asc" ? "▲" : "▼";
+ } else {
+ indicator.textContent = "↕";
+ }
+ button.append(indicator);
+ button.addEventListener("click", () => onSort(column.key));
+ return button;
+ }
+
+ function renderLeaderboard(container, rows, sortState, logoPath) {
+ container.replaceChildren();
+
+ const sortedRows = [...rows].sort((left, right) => compareRows(left, right, sortState));
+
+ const wrapper = document.createElement("div");
+ wrapper.className = "benchmark-table-wrapper";
+
+ 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";
+ } else {
+ sortState.key = key;
+ sortState.direction = key === "modelName" ? "asc" : "desc";
+ }
+ renderLeaderboard(container, rows, sortState, logoPath);
+ };
+
+ for (const column of COLUMN_DEFINITIONS) {
+ const th = document.createElement("th");
+ th.scope = "col";
+ th.append(createHeaderButton(column, sortState, handleSort));
+ headerRow.append(th);
+ }
+
+ thead.append(headerRow);
+ table.append(thead);
+
+ const tbody = document.createElement("tbody");
+
+ sortedRows.forEach((row, index) => {
+ const tr = document.createElement("tr");
+ if (row.hasIcon) {
+ tr.classList.add("is-featured");
+ }
+
+ const rankCell = document.createElement("td");
+ rankCell.className = "benchmark-rank";
+ rankCell.textContent = String(index + 1);
+ tr.append(rankCell);
+
+ const modelCell = document.createElement("td");
+ modelCell.append(createModelCell(row, logoPath));
+ tr.append(modelCell);
+
+ for (const key of ["printed", "handwritten", "total"]) {
+ const scoreCell = document.createElement("td");
+ scoreCell.className = "benchmark-score";
+ scoreCell.textContent = formatScore(row[key]);
+ tr.append(scoreCell);
+ }
+
+ tbody.append(tr);
+ });
+
+ table.append(tbody);
+ wrapper.append(table);
+ container.append(wrapper);
+ }
+
+ function renderError(container, message) {
+ container.replaceChildren();
+ const error = document.createElement("p");
+ error.className = "benchmark-error";
+ error.textContent = message;
+ container.append(error);
+ }
+
+ async function initializeLeaderboard(container) {
+ const logoPath = resolvePath("_static/img/churro.png");
+
+ try {
+ const response = await fetch(resolvePath("_static/data/benchmark_results.json"));
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}`);
+ }
+
+ const rows = await response.json();
+ const sortState = { key: "total", direction: "desc" };
+ renderLeaderboard(container, rows, sortState, logoPath);
+ } catch (error) {
+ renderError(container, "Unable to load the benchmark leaderboard data.");
+ console.error("[benchmark-leaderboard] failed to initialize", error);
+ }
+ }
+
+ function initializeAll() {
+ document.querySelectorAll(".benchmark-leaderboard").forEach((container) => {
+ initializeLeaderboard(container);
+ });
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initializeAll);
+ } else {
+ initializeAll();
+ }
+})();
diff --git a/docs/_templates/sections/sidebar-primary.html b/docs/_templates/sections/sidebar-primary.html
new file mode 100644
index 0000000..73c0236
--- /dev/null
+++ b/docs/_templates/sections/sidebar-primary.html
@@ -0,0 +1,35 @@
+{% block docs_sidebar %}
+{% if theme_navbar_center or theme_navbar_end or sidebars or theme_primary_sidebar_end %}
+
+
+
+{% endif %}
+{% endblock docs_sidebar %}
diff --git a/docs/_templates/sidebar-nav-full.html b/docs/_templates/sidebar-nav-full.html
new file mode 100644
index 0000000..040db6e
--- /dev/null
+++ b/docs/_templates/sidebar-nav-full.html
@@ -0,0 +1,17 @@
+{# Render the full site toctree in the primary sidebar on every page. #}
+{% set show_nav_level = meta['html_theme.show_nav_level'] if (meta is defined and meta is not none and 'html_theme.show_nav_level' in meta) else theme_show_nav_level %}
+
+ {{ _("Documentation") }}
+
+ {{- generate_toctree_html(
+ "sidebar",
+ startdepth=0,
+ show_nav_level=show_nav_level | int,
+ maxdepth=theme_navigation_depth | int,
+ collapse=theme_collapse_navigation | tobool,
+ includehidden=theme_sidebar_includehidden | tobool,
+ titles_only=True
+ )
+ -}}
+
+
diff --git a/docs/_templates/sidebar-toggle.html b/docs/_templates/sidebar-toggle.html
new file mode 100644
index 0000000..fc31514
--- /dev/null
+++ b/docs/_templates/sidebar-toggle.html
@@ -0,0 +1,22 @@
+
diff --git a/docs/api/churro_ocr.md b/docs/api/churro_ocr.md
new file mode 100644
index 0000000..68b87f2
--- /dev/null
+++ b/docs/api/churro_ocr.md
@@ -0,0 +1,9 @@
+# `churro_ocr`
+
+```{eval-rst}
+.. automodule:: churro_ocr
+ :members:
+ :exclude-members: HFChatTemplate, OCRPromptTemplate
+ :imported-members:
+ :show-inheritance:
+```
diff --git a/docs/api/document.md b/docs/api/document.md
new file mode 100644
index 0000000..51649fa
--- /dev/null
+++ b/docs/api/document.md
@@ -0,0 +1,7 @@
+# `churro_ocr.document`
+
+```{eval-rst}
+.. automodule:: churro_ocr.document
+ :members:
+ :show-inheritance:
+```
diff --git a/docs/api/index.md b/docs/api/index.md
new file mode 100644
index 0000000..974cd4e
--- /dev/null
+++ b/docs/api/index.md
@@ -0,0 +1,24 @@
+# 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.
+
+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.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.
+
+```{toctree}
+:maxdepth: 1
+
+churro_ocr
+document
+ocr
+page_detection
+providers
+templates
+prompts
+```
diff --git a/docs/api/ocr.md b/docs/api/ocr.md
new file mode 100644
index 0000000..ad28992
--- /dev/null
+++ b/docs/api/ocr.md
@@ -0,0 +1,7 @@
+# `churro_ocr.ocr`
+
+```{eval-rst}
+.. automodule:: churro_ocr.ocr
+ :members:
+ :show-inheritance:
+```
diff --git a/docs/api/page_detection.md b/docs/api/page_detection.md
new file mode 100644
index 0000000..e0e2044
--- /dev/null
+++ b/docs/api/page_detection.md
@@ -0,0 +1,7 @@
+# `churro_ocr.page_detection`
+
+```{eval-rst}
+.. automodule:: churro_ocr.page_detection
+ :members:
+ :show-inheritance:
+```
diff --git a/docs/api/prompts.md b/docs/api/prompts.md
new file mode 100644
index 0000000..837b6ac
--- /dev/null
+++ b/docs/api/prompts.md
@@ -0,0 +1,8 @@
+# `churro_ocr.prompts`
+
+```{eval-rst}
+.. automodule:: churro_ocr.prompts
+ :members:
+ :imported-members:
+ :show-inheritance:
+```
diff --git a/docs/api/providers.md b/docs/api/providers.md
new file mode 100644
index 0000000..cb8c20b
--- /dev/null
+++ b/docs/api/providers.md
@@ -0,0 +1,26 @@
+# Provider APIs
+
+## `churro_ocr.providers`
+
+```{eval-rst}
+.. automodule:: churro_ocr.providers
+ :members:
+ :imported-members:
+ :show-inheritance:
+```
+
+## `churro_ocr.providers.specs`
+
+```{eval-rst}
+.. automodule:: churro_ocr.providers.specs
+ :members:
+ :show-inheritance:
+```
+
+## `churro_ocr.providers.page_detection`
+
+```{eval-rst}
+.. automodule:: churro_ocr.providers.page_detection
+ :members:
+ :show-inheritance:
+```
diff --git a/docs/api/templates.md b/docs/api/templates.md
new file mode 100644
index 0000000..b987ad0
--- /dev/null
+++ b/docs/api/templates.md
@@ -0,0 +1,8 @@
+# `churro_ocr.templates`
+
+```{eval-rst}
+.. automodule:: churro_ocr.templates
+ :members:
+ :imported-members:
+ :show-inheritance:
+```
diff --git a/docs/benchmarking.md b/docs/benchmarking.md
new file mode 100644
index 0000000..f6a8ea8
--- /dev/null
+++ b/docs/benchmarking.md
@@ -0,0 +1,57 @@
+# Benchmarking
+
+This page is for reproducing CHURRO-DS benchmark runs from a repo checkout.
+
+Run these commands from the repo root after `pixi install`.
+
+For the current committed benchmark snapshot, see the [Benchmark Leaderboard](leaderboard.md). Contributor setup, test commands, and package checks live in [Contributing](contributing.md).
+
+## Smallest Useful Run
+
+The benchmark runner lives in this repo at `tooling.benchmarking.benchmark`.
+
+```bash
+pixi run python -m tooling.benchmarking.benchmark \
+ --backend litellm \
+ --dataset-split test \
+ --model vertex_ai/gemini-2.5-pro
+```
+
+By default, results are written under `workdir/results//`.
+
+## Common Flags
+
+- `--dataset-split dev|test`: choose the CHURRO-DS split
+- `--input-size N`: benchmark only the first `N` selected pages
+- `--offset N`: skip the first `N` selected pages
+- `--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
+- `--vllm-gpu-memory-utilization` and `--vllm-cpu-offload-gb`: pass through selected vLLM runtime knobs
+
+## Output Files
+
+Each benchmark run writes one result directory. The important files are:
+
+- `outputs.json`: one row per evaluated page with the raw predicted text, gold text, and page-level metrics
+- `all_metrics.json`: aggregate metrics grouped across the full run, by main language, by document type, and by the language/type combination
+
+`outputs.json` stores page-level metric values as raw fractions. `all_metrics.json` converts aggregate values to percentages and rounds them to one decimal place. The evaluation pipeline strips the default OCR wrapper tag, flattens supported XML-like OCR output, normalizes whitespace and punctuation, and applies additional Arabic normalization for Arabic and Persian examples.
+
+## Filtering And Slicing
+
+Subset filters are applied before offset and limit:
+
+- `--language` filters on `main_language`
+- `--document-type` filters on `document_type`
+- `--offset` skips rows after filtering
+- `--input-size` limits rows after filtering and offset
+
+That means `--language Arabic --offset 100 --input-size 50` selects rows 101 to 150 from the Arabic-only subset, not from the full split.
+
+## Example Commands
+
+| 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` |
+| Qwen 3.5-0.8B | `Qwen/Qwen3.5-0.8B` | `vllm` | `pixi run python -m tooling.benchmarking.benchmark --backend vllm --dataset-split test --model Qwen/Qwen3.5-0.8B --output-dir workdir/results/test/vllm_Qwen_Qwen3.5-0.8B` |
diff --git a/docs/cli.md b/docs/cli.md
new file mode 100644
index 0000000..d47f119
--- /dev/null
+++ b/docs/cli.md
@@ -0,0 +1,95 @@
+# CLI
+
+Use the CLI when you want a quick sanity check before writing Python code.
+
+Use `churro-ocr --help` or `python -m churro_ocr --help` to inspect the top-level commands.
+
+## Command Summary
+
+| Command | Use it when |
+| --- | --- |
+| `transcribe` | you want OCR text for one image |
+| `extract-pages` | you want page crops from an image or PDF |
+
+## `transcribe` Examples
+
+### OCR One Image
+
+```bash
+churro-ocr transcribe \
+ --image scan.png \
+ --backend litellm \
+ --model vertex_ai/gemini-2.5-flash
+```
+
+### OCR With A Local OpenAI-compatible Server
+
+```bash
+churro-ocr transcribe \
+ --image scan.png \
+ --backend openai-compatible \
+ --model local-model \
+ --base-url http://127.0.0.1:8000/v1 \
+ --api-key dummy
+```
+
+## `extract-pages` Examples
+
+### Extract Pages From An Image
+
+```bash
+churro-ocr extract-pages \
+ --image spread.jpg \
+ --output-dir pages/
+```
+
+This writes sequential PNG files such as `page_0000.png`, `page_0001.png`, and so on, and prints each written path to stdout.
+
+### Extract Pages With Azure Page Detection
+
+```bash
+churro-ocr extract-pages \
+ --image spread.jpg \
+ --output-dir pages/ \
+ --page-detector azure \
+ --endpoint https://.cognitiveservices.azure.com/ \
+ --api-key
+```
+
+### Extract Pages From A PDF
+
+```bash
+churro-ocr extract-pages \
+ --pdf document.pdf \
+ --output-dir pages/ \
+ --dpi 300 \
+ --trim-margin 30
+```
+
+## Command Contracts
+
+### `transcribe` Backends
+
+| `--backend` value | Required flags | Notes |
+| --- | --- | --- |
+| `litellm` | `--model` | Uses LiteLLM credentials and routing. `--base-url`, `--api-key`, and `--api-version` are optional transport overrides. |
+| `openai-compatible` | `--model`, `--base-url`, `--api-key` | For local or self-hosted OpenAI-style servers. |
+| `azure` | `--endpoint`, `--api-key` | `--model` is optional. |
+| `mistral` | `--api-key` | `--model` defaults to `mistral-ocr-latest`. |
+| `hf` | `--model` | Local Transformers OCR. |
+| `vllm` | `--model` | Local vLLM OCR. |
+
+### `extract-pages` Detectors
+
+| `--page-detector` value | Required flags | Notes |
+| --- | --- | --- |
+| `none` | none | Default behavior. Treats the whole image or rasterized PDF page as one crop. |
+| `llm` | `--model` | Uses `LLMPageDetector`. `--base-url`, `--api-key`, and `--api-version` are optional transport overrides. |
+| `azure` | `--endpoint`, `--api-key` | Uses Azure Document Intelligence layout detection. |
+
+## Additional Rules
+
+- `transcribe` requires exactly one `--image`.
+- `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
new file mode 100644
index 0000000..9e8786a
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,118 @@
+"""Sphinx configuration for the Churro OCR documentation site."""
+
+from __future__ import annotations
+
+from pathlib import Path
+import shutil
+import sys
+import tomllib
+
+DOCS_DIR = Path(__file__).resolve().parent
+ROOT = DOCS_DIR.parent
+SRC = ROOT / "src"
+
+sys.path.insert(0, str(SRC))
+
+project_data = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
+project = "Churro OCR"
+author = ", ".join(item["name"] for item in project_data["project"]["authors"])
+release = project_data["project"]["version"]
+version = ".".join(release.split(".")[:2])
+
+extensions = [
+ "myst_parser",
+ "sphinx.ext.autodoc",
+ "sphinx.ext.githubpages",
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.napoleon",
+ "sphinx.ext.viewcode",
+ "sphinx_copybutton",
+ "sphinx_design",
+]
+
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store", "pypi.md"]
+source_suffix = {".md": "markdown"}
+root_doc = "index"
+language = "en"
+
+myst_enable_extensions = [
+ "attrs_block",
+ "attrs_inline",
+ "colon_fence",
+ "deflist",
+ "fieldlist",
+]
+myst_heading_anchors = 3
+
+autodoc_class_signature = "separated"
+autodoc_member_order = "bysource"
+autodoc_preserve_defaults = True
+autodoc_typehints = "description"
+autoclass_content = "both"
+
+napoleon_google_docstring = False
+napoleon_numpy_docstring = False
+
+intersphinx_mapping = {
+ "pillow": ("https://pillow.readthedocs.io/en/stable/", None),
+ "python": ("https://docs.python.org/3", None),
+}
+
+html_theme = "pydata_sphinx_theme"
+html_title = f"{project} Documentation"
+html_favicon = "_static/img/churro.png"
+html_static_path = ["_static"]
+templates_path = ["_templates"]
+html_css_files = ["css/custom.css"]
+html_js_files = ["js/benchmark-leaderboard.js"]
+html_sidebars = {
+ "**": ["sidebar-nav-full.html"],
+}
+
+html_theme_options = {
+ "header_links_before_dropdown": 0,
+ "icon_links": [
+ {
+ "name": "GitHub",
+ "url": project_data["project"]["urls"]["Repository"],
+ "icon": "fa-brands fa-github",
+ },
+ ],
+ "logo": {
+ "text": "",
+ },
+ "navbar_align": "left",
+ "navbar_center": [],
+ "footer_end": [],
+ "footer_start": [],
+ "navigation_with_keys": True,
+ "search_as_you_type": True,
+ "secondary_sidebar_items": ["page-toc", "edit-this-page"],
+ "show_toc_level": 2,
+ "use_edit_page_button": True,
+}
+
+html_context = {
+ "github_user": "stanford-oval",
+ "github_repo": "Churro",
+ "github_version": "main",
+ "doc_path": "docs",
+}
+
+
+def _copy_build_artifacts(app, exception) -> None:
+ if exception is not None:
+ return
+
+ benchmark_output_dir = Path(app.outdir) / "_static" / "data"
+ benchmark_output_dir.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(
+ ROOT / "benchmark_results.json",
+ benchmark_output_dir / "benchmark_results.json",
+ )
+
+ shutil.copytree(ROOT / "static", Path(app.outdir) / "static", dirs_exist_ok=True)
+
+
+def setup(app) -> None:
+ app.connect("build-finished", _copy_build_artifacts)
diff --git a/docs/contributing.md b/docs/contributing.md
new file mode 100644
index 0000000..df6c94a
--- /dev/null
+++ b/docs/contributing.md
@@ -0,0 +1,89 @@
+# Contributing
+
+Use Pixi for local development.
+
+## Setup
+
+Install Pixi, then create the default environment from the repo root:
+
+```bash
+pixi install
+```
+
+## Git Hooks
+
+Install the repo hooks after the Pixi environment is ready:
+
+```bash
+pixi exec pre-commit install --install-hooks
+```
+
+The hook configuration delegates to the existing Pixi tasks:
+
+- `pre-commit`: `pixi run format`, `pixi run lint`, `pixi run typecheck`
+- `pre-push`: `pixi run test`
+- `manual`: `pixi run docs-build`, `pixi run package-check`
+
+You can invoke each stage manually from the repo root:
+
+```bash
+pixi exec pre-commit run --all-files
+pixi exec pre-commit run --hook-stage pre-push --all-files
+pixi exec pre-commit run --hook-stage manual --all-files
+```
+
+`pixi exec pre-commit run --all-files` now runs formatting, linting, and type checking in one pass.
+
+## Common Commands
+
+Run these from the repo root:
+
+```bash
+pixi run format
+pixi run lint
+pixi run typecheck
+pixi run test
+pixi run coverage
+pixi run docs-build
+pixi run docs-serve
+pixi run package-check
+```
+
+What they do:
+
+- `format`: run Ruff formatting on `src/` and `tests/`
+- `lint`: run Ruff lint checks on `src/` and `tests/`
+- `typecheck`: run `ty` on `src/` and `tests/`
+- `test`: run the unit and offline test suite
+- `coverage`: run the full coverage report used for release audits
+- `docs-build`: build the static documentation site into `docs/_build/html`
+- `docs-serve`: start a live-reload documentation preview server on an automatically selected free port
+- `package-check`: build and audit the wheel and sdist that would be published to PyPI
+
+## Live Integration Tests
+
+Live provider integration tests are skipped by default.
+
+- `tests/test_page_detection_integration.py` uses `CHURRO_RUN_LIVE_VERTEX_TESTS`
+- `tests/test_hf_ocr_integration.py` uses `CHURRO_RUN_LIVE_HF_TESTS`
+
+These tests may require credentials, external services, and billable APIs. Do not enable them in routine local runs unless you intend to use those services.
+
+## Package Check
+
+`pixi run package-check` runs the repo-local publish gate defined in `scripts/package_check.py`. It validates the built artifacts, not the editable checkout.
+
+The current package check does all of the following:
+
+- removes stale `build/`, `dist/`, and generated egg-info directories
+- builds a fresh wheel and sdist
+- runs `twine check` on both artifacts
+- verifies the wheel metadata, including project URLs, extras, and the `churro-ocr` console entry point
+- verifies that repo-only content such as `tests/`, `tooling/`, `scripts/`, and release audit notes do not ship inside the artifacts
+- smoke-installs the base wheel and base sdist in clean virtual environments and checks `import churro_ocr`, `import churro_ocr.providers`, and `python -m churro_ocr --help`
+- smoke-installs the lightweight `local` and `pdf` extras from the built wheel
+- audits direct dependency licenses for incompatible or unknown licenses
+
+If `package-check` fails, treat that as a release blocker until the artifact or documentation contract is fixed.
+
+For benchmark runs and evaluation outputs from a repo checkout, see [Benchmarking](benchmarking.md).
diff --git a/docs/core-concepts.md b/docs/core-concepts.md
new file mode 100644
index 0000000..508c758
--- /dev/null
+++ b/docs/core-concepts.md
@@ -0,0 +1,201 @@
+# Core Concepts
+
+Churro is easier to understand if you think about it as a document pipeline, not just an OCR call. The library takes an input source, turns it into one or more page objects, runs OCR on those page objects, and returns results that still preserve page-level structure.
+
+If you keep that model in mind, the rest of the API becomes much simpler.
+
+## The Mental Model
+
+Most workflows follow the same shape:
+
+1. Choose an OCR backend.
+2. Start from an input source such as an image, a photographed spread, or a PDF.
+3. Optionally detect page boundaries.
+4. Work with one or more `DocumentPage` objects.
+5. Attach OCR output to those pages.
+
+In practice, the flow looks like this:
+
+```text
+raw image or PDF
+ -> optional page detection
+ -> one or more DocumentPage objects
+ -> OCR
+ -> pages with text, model info, and metadata
+```
+
+The key idea is that Churro keeps the page object all the way through the pipeline. You do not lose the cropped page image, page ordering, or page-level metadata just because OCR has been run.
+
+## Start With The Shape Of Your Input
+
+The easiest way to choose an API is to ask what your input already looks like.
+
+| If your input looks like this | Start with | Why |
+| --- | --- | --- |
+| One image already equals one page | `OCRClient` | no page detection is needed |
+| One image may contain multiple pages | `DocumentOCRPipeline` or `DocumentPageDetector` | detect crops first, then OCR |
+| A PDF | `DocumentOCRPipeline` | rasterization, page detection, and OCR are handled for you |
+| You only want page crops, not text yet | `DocumentPageDetector` | detection-only workflow |
+| You want fine control over provider setup | `OCRBackendSpec` + `build_ocr_backend(...)` | backend configuration stays explicit |
+
+This is the most important rule of thumb in the library: do not add page detection unless your input actually needs it.
+
+## The Main Building Blocks
+
+These are the public types most users need to understand.
+
+| Object | What it represents | When you use it |
+| --- | --- | --- |
+| `OCRBackendSpec` | a declarative description of which provider and model to use | when configuring OCR backends |
+| `build_ocr_backend(...)` | the factory that turns a spec into a runnable backend | right after choosing a provider |
+| `OCRClient` | OCR for a single page image or a single `DocumentPage` | when each image is already one page |
+| `DocumentPageDetector` | page detection without OCR | when you need crops only |
+| `DocumentOCRPipeline` | page detection plus OCR in one workflow | when working with photographed spreads or PDFs |
+| `DocumentPage` | the central page object passed through detection and OCR | almost everywhere |
+
+For most applications, `DocumentPage` is the type to pay attention to. The other APIs mainly exist to create, transform, or enrich `DocumentPage` objects.
+
+## `DocumentPage` Is The Core Object
+
+A `DocumentPage` is one page image plus whatever Churro knows about that page.
+
+Before OCR, a page may only have:
+
+- an image
+- a page position
+- source metadata
+- crop information such as `bbox` or `polygon`
+
+After OCR, the same page object can also have:
+
+- `text`
+- `provider_name`
+- `model_name`
+- `ocr_metadata`
+
+That makes it easy to treat page detection and OCR as one continuous workflow instead of converting between unrelated result types.
+
+```python
+from churro_ocr import DocumentPage, OCRClient
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+)
+
+page = DocumentPage.from_image_path("scan.png")
+ocr_page = OCRClient(backend).ocr(page)
+
+print(ocr_page.text)
+print(ocr_page.provider_name)
+print(ocr_page.model_name)
+```
+
+If your input is already one page per image, this is the simplest mental model: create or load a page, run OCR, then read the text from the returned page object.
+
+## How Detection And OCR Fit Together
+
+Churro separates page detection from OCR, but the two parts compose cleanly.
+
+- `DocumentPageDetector` answers: "What are the page crops in this source?"
+- `OCRClient` answers: "What text is on this one page?"
+- `DocumentOCRPipeline` answers: "Take this document-shaped input and do the whole thing."
+
+That means the library works well across very different input shapes:
+
+- scanned pages where each image is already clean and single-page
+- photographed book spreads where one image contains two visible pages
+- PDFs that must be rasterized before OCR
+
+If you want concrete usage examples for each case, see [OCR Workflows](guides/ocr-workflows.md) and [Page Detection](guides/page-detection.md).
+
+## What The Result Types Mean
+
+The result containers are small, but they serve different purposes.
+
+| Type | What you get | Typical use |
+| --- | --- | --- |
+| `DocumentPage` | one page image, with or without OCR attached | most page-level code |
+| `OCRResult` | plain OCR output without the page image | backend-facing code or `DocumentOCRResult.as_ocr_results()` |
+| `PageDetectionResult` | detected pages from one image or PDF | detection-only workflows |
+| `DocumentOCRResult` | OCR output across all pages in a document workflow | PDFs, spreads, or batched page flows |
+
+`OCRResult` is the least user-facing type here. Most application code can stay at the `DocumentPage` or `DocumentOCRResult` level.
+
+`DocumentOCRResult` is especially useful when you want both page structure and convenience helpers:
+
+- `result.pages` keeps the full page objects
+- `result.texts()` returns plain text per page
+- `result.as_ocr_results()` converts to lightweight OCR-only results
+
+## Understanding `page_index` And `source_index`
+
+These two fields are easy to confuse, but they capture different ideas.
+
+- `page_index` is the page position in the current output.
+- `source_index` is the index of the original source item that produced that page.
+
+Examples make this clearer:
+
+- If `scan.png` is a single-page image, the page will usually have `page_index=0` and `source_index=0`.
+- If `spread.jpg` contains two detected pages, the output pages may have `page_index=0` and `page_index=1`, but both still came from the same source image, so both have `source_index=0`.
+- If a PDF has 10 pages and each PDF page becomes one detected page, the output pages will usually have matching `page_index` and `source_index`.
+- If one PDF page is split into multiple detected crops, those crops get different `page_index` values but share the same `source_index` because they came from the same original PDF page.
+
+In short, `page_index` tells you where a page ended up in the output sequence. `source_index` tells you where it came from.
+
+## Understanding `metadata` And `ocr_metadata`
+
+Churro keeps caller-side and provider-side metadata separate on purpose.
+
+- `metadata` is your own metadata, or metadata produced during page detection.
+- `ocr_metadata` is metadata returned by the OCR provider for that page.
+
+This separation matters because the two kinds of metadata usually have different meanings.
+
+Examples of `metadata`:
+
+- a job ID you attach when submitting work
+- page detection hints
+- page ordering or dataset labels
+
+Examples of `ocr_metadata`:
+
+- provider response fields
+- usage or timing information
+- model-specific OCR details
+
+On document-level results, `source_type` tells you whether the document came from an `"image"` or a `"pdf"` workflow.
+
+## Sync And Async
+
+Every high-level sync entrypoint has an async equivalent.
+
+| Sync | Async |
+| --- | --- |
+| `ocr(...)` | `aocr(...)` |
+| `ocr_image(...)` | `aocr_image(...)` |
+| `detect_image_sync(...)` | `detect_image(...)` |
+| `process_image_sync(...)` | `process_image(...)` |
+| `process_pdf_sync(...)` | `process_pdf(...)` |
+
+The default choice for most users is still the sync API. Use the async forms when:
+
+- you are already inside an async application
+- you want to coordinate OCR with other async work
+- you want to manage concurrency explicitly
+
+If you use `DocumentOCRPipeline`, the `max_concurrency` setting controls how many page OCR jobs run at once inside that pipeline.
+
+## Practical Rules Of Thumb
+
+- Use `OCRClient` when each input image is already a page.
+- Use `DocumentOCRPipeline` for PDFs and photographed spreads.
+- Use `DocumentPageDetector` when you want crops without OCR.
+- Build your backend once and reuse it across calls.
+- Pass exactly one of `image` or `image_path` when an API accepts both.
+
+When you want concrete recipes, continue with [OCR Workflows](guides/ocr-workflows.md). When you need exact signatures and fields, use the [API Reference](api/index.md).
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..1dbf321
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,78 @@
+# 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`.
+
+## Which API Should You Use?
+
+| 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` |
+
+## Install Only What You Need
+
+```bash
+pip install churro-ocr
+pip install "churro-ocr[llm]"
+pip install "churro-ocr[local]"
+pip install "churro-ocr[hf]"
+pip install "churro-ocr[vllm]"
+pip install "churro-ocr[azure]"
+pip install "churro-ocr[mistral]"
+pip install "churro-ocr[pdf]"
+pip install "churro-ocr[all]"
+```
+
+## Provider Extras
+
+| Extra | Use it when |
+| --- | --- |
+| `llm` | you want hosted multimodal OCR and LLM-based page detection through LiteLLM |
+| `local` | you have a local or self-hosted OpenAI-compatible server |
+| `hf` | you want local Transformers inference in-process |
+| `vllm` | you want higher-throughput local serving |
+| `azure` | you want Azure Document Intelligence OCR or layout detection |
+| `mistral` | you want Mistral OCR |
+| `pdf` | you want PDF rasterization through `pypdfium2` |
+| `all` | you want every supported backend and utility extra |
+
+## First OCR Example
+
+Use `OCRClient` when your input is already one page per image.
+
+```python
+from churro_ocr.ocr import OCRClient
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+)
+
+page = OCRClient(backend).ocr_image(image_path="scan.png")
+
+print(page.text)
+print(page.provider_name)
+print(page.model_name)
+```
+
+When an API accepts both `image` and `image_path`, pass exactly one of them.
+
+## Quick CLI Sanity Check
+
+Use the CLI when you want to confirm a model or backend before writing Python code.
+
+```bash
+churro-ocr transcribe \
+ --image scan.png \
+ --backend hf \
+ --model stanford-oval/churro-3B
+```
+
+## Working From A Repo Checkout
+
+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/historical-document-xml.md b/docs/guides/historical-document-xml.md
new file mode 100644
index 0000000..2d0a234
--- /dev/null
+++ b/docs/guides/historical-document-xml.md
@@ -0,0 +1,141 @@
+# HistoricalDocument XML
+
+`HistoricalDocument` is the structured XML format used by CHURRO for rich transcriptions of historical sources.
+
+Use it when you want more than plain OCR text, for example when you need:
+
+- page-level structure rather than one flat string
+- header, body, and footer separation
+- inline markup for additions, deletions, and missing text
+- a representation that can preserve reading order while still carrying layout-aware detail
+
+You do **not** need this format to use the `churro-ocr` library. The public OCR APIs work perfectly well with plain text in `page.text`. This guide is specifically for understanding CHURRO-style structured outputs and the repo-local evaluation helpers around them.
+
+## Example
+
+```xml
+
+
+ lat
+
+
+
+
+
+
+ In nomine domini amen.
+
+ Nos humiles notarii
+
+ subscripsimus.
+
+
+
+ Memorandum de censu.
+
+
+
+
+
+```
+
+That example shows the main idea:
+
+- document metadata is separate from page content
+- page content is divided into logical sections
+- inline editorial markup can appear inside lines without losing reading order
+
+## Document Structure
+
+A typical document has:
+
+- a root `` element
+- optional `` describing languages, scripts, writing direction, or notes
+- one or more `` blocks
+- optional `` and `` sections per page
+- a `` section containing the main reading-order content
+
+Within a page body, CHURRO-style XML can include structural tags such as:
+
+- ``
+- ``
+- ``
+- ``
+
+It can also include inline markup such as:
+
+- ``
+- ``
+- ` `
+- ``
+- ``
+
+Those tags let the output capture features that plain OCR text would otherwise lose.
+
+## Why Use XML Instead Of Plain Text?
+
+Plain OCR text is usually the right output when you only need readable transcription. Structured XML is useful when you need to preserve editorial or layout-aware distinctions that affect downstream work.
+
+Common cases:
+
+- scholarly editing or diplomatic transcription
+- distinguishing marginal notes from body text
+- keeping track of gaps or illegible regions
+- preserving additions or deletions for later analysis
+- running evaluation on structured outputs before flattening them
+
+## How The Repo Flattens XML For Evaluation
+
+The repo-local evaluation helper `tooling.evaluation.xml_utils.extract_actual_text_from_xml()` converts `HistoricalDocument` XML into plain text for benchmarking and metric calculation.
+
+Its behavior is intentionally lossy:
+
+- if the input does not contain `HistoricalDocument`, it returns the input unchanged
+- it removes ``, ``, ``, and `` before parsing
+- it then extracts text from each page’s ``, ``, and `` in document order
+- it joins sections within a page using single newlines
+- it joins pages using blank lines
+- if XML parsing fails, it returns an empty string
+
+That means evaluation is focused on readable recovered text, not on preserving every markup distinction in the structured output.
+
+## What Gets Lost During Flattening
+
+Once XML is reduced to plain text:
+
+- section boundaries become newline conventions
+- deleted or illegible spans are dropped
+- gaps are removed rather than represented explicitly
+- metadata is not preserved
+- structural distinctions such as paragraph versus marginal note are flattened into text order
+
+If you care about those distinctions, keep the XML as your source of truth and flatten only for downstream tasks that truly require plain text.
+
+## Namespace Handling
+
+The evaluation helper matches elements by local tag name, so namespaced XML still works as long as the local names are the expected ones such as `HistoricalDocument`, `Page`, `Header`, `Body`, and `Footer`.
+
+This is why XML such as:
+
+```xml
+
+
+
+ Example text.
+
+
+
+```
+
+can still be flattened correctly by the evaluation tooling.
+
+## Practical Guidance
+
+- Store the raw XML if you may need structure later.
+- Use flattened text only for search, quick display, or text-level evaluation metrics.
+- Treat the flattened output as a derived view, not as a lossless representation.
+- When comparing systems, make sure they are flattened the same way before scoring.
diff --git a/docs/guides/ocr-workflows.md b/docs/guides/ocr-workflows.md
new file mode 100644
index 0000000..f352c79
--- /dev/null
+++ b/docs/guides/ocr-workflows.md
@@ -0,0 +1,168 @@
+# OCR Workflows
+
+This page covers the common user-facing flows: single-image OCR, PDFs, multi-page photographed spreads, and async entry points.
+
+## OCR One Image
+
+Use `OCRClient` when each input image already represents one page.
+
+```python
+from churro_ocr.ocr import OCRClient
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+)
+
+page = OCRClient(backend).ocr_image(image_path="scan.png")
+
+print(page.text)
+print(page.provider_name)
+print(page.model_name)
+```
+
+## OCR A PDF
+
+If you install the `pdf` extra, `DocumentOCRPipeline` can rasterize a PDF and OCR each page.
+
+```python
+from churro_ocr import DocumentOCRPipeline
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+pipeline = DocumentOCRPipeline(
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+ ),
+ max_concurrency=4,
+)
+
+result = pipeline.process_pdf_sync("document.pdf", dpi=300, trim_margin=30)
+
+for page in result.pages:
+ print(page.page_index, page.text)
+```
+
+## Detect Pages And OCR A Photographed Spread
+
+This flow is useful when one input image contains multiple pages.
+
+```python
+from pathlib import Path
+
+from churro_ocr import DocumentOCRPipeline, PageDetectionRequest
+from churro_ocr.providers import (
+ LLMPageDetector,
+ LiteLLMTransportConfig,
+ OCRBackendSpec,
+ build_ocr_backend,
+)
+
+INPUT_IMAGE = Path("spread.jpg")
+OUTPUT_DIR = Path("output")
+MODEL = "vertex_ai/gemini-2.5-flash"
+
+transport = LiteLLMTransportConfig()
+
+ocr_backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model=MODEL,
+ transport=transport,
+ )
+)
+
+pipeline = DocumentOCRPipeline(
+ ocr_backend,
+ detection_backend=LLMPageDetector(
+ model=MODEL,
+ transport=transport,
+ ),
+ max_concurrency=4,
+)
+
+result = pipeline.process_image_sync(
+ PageDetectionRequest(
+ image_path=INPUT_IMAGE,
+ trim_margin=20,
+ )
+)
+
+OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
+
+for page in result.pages:
+ image_path = OUTPUT_DIR / f"page_{page.page_index:04d}.png"
+ text_path = OUTPUT_DIR / f"page_{page.page_index:04d}.txt"
+
+ page.image.save(image_path)
+ text_path.write_text(page.text or "", encoding="utf-8")
+```
+
+If your input is already one page per image, skip the `detection_backend` and use `OCRClient`.
+
+## Async Entry Points
+
+Every sync helper has an async equivalent.
+
+### Async OCR For One Page
+
+```python
+import asyncio
+
+from churro_ocr.ocr import OCRClient
+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",
+ )
+ )
+ page = await OCRClient(backend).aocr_image(
+ image_path="scan.png",
+ page_index=3,
+ source_index=7,
+ metadata={"job_id": "demo"},
+ )
+ print(page.text)
+ print(page.metadata)
+
+
+asyncio.run(main())
+```
+
+### Async Document OCR
+
+```python
+import asyncio
+
+from churro_ocr import DocumentOCRPipeline, PageDetectionRequest
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+
+async def main() -> None:
+ pipeline = DocumentOCRPipeline(
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+ ),
+ max_concurrency=4,
+ )
+ image_result = await pipeline.process_image(
+ PageDetectionRequest(image_path="spread.jpg", trim_margin=20),
+ ocr_metadata={"job_id": "demo-image"},
+ )
+ print(image_result.texts())
+
+
+asyncio.run(main())
+```
diff --git a/docs/guides/page-detection.md b/docs/guides/page-detection.md
new file mode 100644
index 0000000..25f2269
--- /dev/null
+++ b/docs/guides/page-detection.md
@@ -0,0 +1,74 @@
+# Page Detection
+
+Use `DocumentPageDetector` when you want page crops without OCR.
+
+## 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 |
+
+## Default Detector
+
+When you do not provide a backend, the detector treats the whole image as one page.
+
+```python
+from churro_ocr.page_detection import DocumentPageDetector, PageDetectionRequest
+
+result = DocumentPageDetector().detect_image_sync(
+ PageDetectionRequest(image_path="scan.png")
+)
+
+for page in result.pages:
+ print(page.page_index, page.image.size)
+```
+
+## Azure-backed Page Detection
+
+Use `AzurePageDetector` when you want Azure Document Intelligence to find pages from an image or rasterized PDF page.
+
+```python
+from churro_ocr.page_detection import DocumentPageDetector, PageDetectionRequest
+from churro_ocr.providers import AzurePageDetector
+
+detector = DocumentPageDetector(
+ backend=AzurePageDetector(
+ endpoint="https://.cognitiveservices.azure.com/",
+ api_key="",
+ )
+)
+
+result = detector.detect_image_sync(
+ PageDetectionRequest(image_path="scan.png", trim_margin=30)
+)
+```
+
+## LLM-based Page Detection
+
+Use `LLMPageDetector` when you want a multimodal model to identify page boundaries.
+
+```python
+from churro_ocr.page_detection import DocumentPageDetector, PageDetectionRequest
+from churro_ocr.providers import LLMPageDetector, LiteLLMTransportConfig
+
+detector = DocumentPageDetector(
+ backend=LLMPageDetector(
+ model="vertex_ai/gemini-2.5-flash",
+ transport=LiteLLMTransportConfig(),
+ )
+)
+
+result = detector.detect_image_sync(
+ PageDetectionRequest(image_path="spread.jpg", trim_margin=20)
+)
+```
+
+## Important Inputs
+
+- `image` and `image_path` are mutually exclusive on `PageDetectionRequest`.
+- `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.
diff --git a/docs/guides/providers.md b/docs/guides/providers.md
new file mode 100644
index 0000000..08e5b6f
--- /dev/null
+++ b/docs/guides/providers.md
@@ -0,0 +1,229 @@
+# Providers And Configuration
+
+All Churro OCR backends use the same builder entry point:
+
+```python
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+)
+```
+
+## Which OCR Backend Should You Use?
+
+| Provider | Install extra | Good default when |
+| --- | --- | --- |
+| `litellm` | `llm` | you want hosted multimodal models routed through LiteLLM |
+| `openai-compatible` | `local` | you have a local or self-hosted OpenAI-style server |
+| `hf` | `hf` | you want local Transformers inference in-process |
+| `vllm` | `vllm` | you want higher-throughput local serving |
+| `azure` | `azure` | you want Azure Document Intelligence OCR |
+| `mistral` | `mistral` | you want Mistral OCR |
+
+## Recommended Starting Points
+
+| Situation | Good default | Why |
+| --- | --- | --- |
+| 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 |
+| higher-throughput local serving | `vllm` + `stanford-oval/churro-3B` | better fit when you want a served local backend |
+
+## Hosted Providers
+
+### LiteLLM
+
+```python
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+)
+```
+
+Override transport or completion settings when you need to:
+
+```python
+from churro_ocr.providers import LiteLLMTransportConfig, OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="gpt-4.1-mini",
+ transport=LiteLLMTransportConfig(
+ api_base="https://example.invalid/v1",
+ api_key="secret",
+ api_version="2025-01-01-preview",
+ completion_kwargs={"temperature": 0},
+ ),
+ )
+)
+```
+
+### Azure Document Intelligence
+
+```python
+from churro_ocr.providers import (
+ AzureDocumentIntelligenceOptions,
+ OCRBackendSpec,
+ build_ocr_backend,
+)
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="azure",
+ options=AzureDocumentIntelligenceOptions(
+ endpoint="https://.cognitiveservices.azure.com/",
+ api_key="",
+ ),
+ )
+)
+```
+
+### Mistral OCR
+
+```python
+from churro_ocr.providers import MistralOptions, OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="mistral",
+ model="mistral-ocr-latest",
+ options=MistralOptions(api_key=""),
+ )
+)
+```
+
+## Local And Self-Hosted Providers
+
+### OpenAI-compatible
+
+```python
+from churro_ocr.providers import (
+ LiteLLMTransportConfig,
+ OCRBackendSpec,
+ build_ocr_backend,
+)
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="openai-compatible",
+ model="local-model",
+ transport=LiteLLMTransportConfig(
+ api_base="http://127.0.0.1:8000/v1",
+ api_key="dummy",
+ ),
+ )
+)
+```
+
+### Hugging Face
+
+```python
+from churro_ocr.providers import HuggingFaceOptions, 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"},
+ ),
+ )
+)
+```
+
+### vLLM
+
+```python
+from churro_ocr.providers import OCRBackendSpec, VLLMOptions, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="vllm",
+ model="stanford-oval/churro-3B",
+ options=VLLMOptions(),
+ )
+)
+```
+
+## `OCRBackendSpec` Reference
+
+| Field | Meaning |
+| --- | --- |
+| `provider` | One of `litellm`, `openai-compatible`, `azure`, `mistral`, `hf`, or `vllm`. |
+| `model` | Required for `litellm`, `openai-compatible`, `hf`, and `vllm`. Optional for `azure`. Defaults to `mistral-ocr-latest` when omitted for `mistral`. |
+| `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. |
+| `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. |
+| `VLLMOptions` | `vllm` | None | Carries runtime and sampling settings for vLLM. |
+| `AzureDocumentIntelligenceOptions` | `azure` | `endpoint`, `api_key` | `model` is optional for Azure OCR in `OCRBackendSpec`. |
+| `MistralOptions` | `mistral` | `api_key` | `model` defaults to `mistral-ocr-latest` when omitted. |
+
+## 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`.
+
+```python
+from churro_ocr import HFChatTemplate
+from churro_ocr.providers import (
+ HuggingFaceOptions,
+ OCRBackendSpec,
+ OCRModelProfile,
+ 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"}),
+ )
+)
+```
+
+### Prompt And Template Exports
+
+Useful public template exports:
+
+| 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`. |
+| `DOTS_OCR_1_5_OCR_TEMPLATE` | `churro_ocr.templates` | Built-in template for `kristaller486/dots.ocr-1.5`. |
+| `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. |
+| `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. |
+| `strip_ocr_output_tag(...)` | `churro_ocr.prompts` | Remove the default OCR wrapper tag from model output. |
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..51daa0b
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,48 @@
+```{include} ../README.md
+:end-before: "## Citation"
+```
+
+```{toctree}
+:hidden:
+:maxdepth: 1
+
+Overview
+Getting Started
+```
+
+```{toctree}
+:hidden:
+:maxdepth: 1
+:caption: Use CHURRO
+
+guides/ocr-workflows
+guides/page-detection
+guides/providers
+cli
+```
+
+```{toctree}
+:hidden:
+:maxdepth: 1
+:caption: Concepts
+
+core-concepts
+guides/historical-document-xml
+```
+
+```{toctree}
+:hidden:
+:maxdepth: 1
+:caption: Benchmark
+
+leaderboard
+benchmarking
+```
+
+```{toctree}
+:hidden:
+:maxdepth: 1
+
+api/index
+contributing
+```
diff --git a/docs/leaderboard.md b/docs/leaderboard.md
new file mode 100644
index 0000000..fd1028e
--- /dev/null
+++ b/docs/leaderboard.md
@@ -0,0 +1,11 @@
+# Benchmark Leaderboard
+
+
+
+
+
+Printed
+: performance on printed-document pages.
+
+Handwritten
+: performance on handwritten-document pages.
diff --git a/docs/pypi.md b/docs/pypi.md
new file mode 100644
index 0000000..6d55bf2
--- /dev/null
+++ b/docs/pypi.md
@@ -0,0 +1,57 @@
+# churro-ocr
+
+`churro-ocr` is a Python toolkit for OCR and page detection on historical documents.
+
+Full documentation and project overview live at https://stanford-oval.github.io/Churro/.
+
+## Install
+
+Install only the pieces you need:
+
+```bash
+pip install churro-ocr
+pip install "churro-ocr[llm]"
+pip install "churro-ocr[local]"
+pip install "churro-ocr[hf]"
+pip install "churro-ocr[vllm]"
+pip install "churro-ocr[azure]"
+pip install "churro-ocr[mistral]"
+pip install "churro-ocr[pdf]"
+pip install "churro-ocr[all]"
+```
+
+## Which API Should You Use?
+
+| 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 backend/provider options directly | `build_ocr_backend(...)` + `OCRBackendSpec` |
+
+## Quick Start
+
+```python
+from churro_ocr.ocr import OCRClient
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+
+backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="vertex_ai/gemini-2.5-flash",
+ )
+)
+
+page = OCRClient(backend).ocr_image(image_path="scan.png")
+
+print(page.text)
+```
+
+## More
+
+- Overview: https://stanford-oval.github.io/Churro/
+- Getting started: https://stanford-oval.github.io/Churro/getting-started.html
+- Benchmark leaderboard: https://stanford-oval.github.io/Churro/leaderboard.html
+- Provider setup: https://stanford-oval.github.io/Churro/guides/providers.html
+- CLI docs: https://stanford-oval.github.io/Churro/cli.html
+- API reference: https://stanford-oval.github.io/Churro/api/index.html
diff --git a/evaluation/__init__.py b/evaluation/__init__.py
deleted file mode 100644
index e754b4b..0000000
--- a/evaluation/__init__.py
+++ /dev/null
@@ -1,20 +0,0 @@
-"""Evaluation utilities for OCR outputs."""
-
-from .evaluate_page import (
- batch_evaluate,
- calculate_metrics,
- calculate_metrics_from_text,
- evaluate_page,
- initialize_metrics,
-)
-from .metrics import compute_metrics
-
-
-__all__ = [
- "batch_evaluate",
- "calculate_metrics",
- "calculate_metrics_from_text",
- "compute_metrics",
- "evaluate_page",
- "initialize_metrics",
-]
diff --git a/evaluation/evaluate_page.py b/evaluation/evaluate_page.py
deleted file mode 100644
index b1ceb1f..0000000
--- a/evaluation/evaluate_page.py
+++ /dev/null
@@ -1,206 +0,0 @@
-import multiprocessing
-from typing import Any
-
-import evaluate
-from rapidfuzz import distance as rf_distance
-from tqdm import tqdm
-
-from churro.utils.log_utils import logger
-
-from .normalization import normalize_text_for_evaluation
-from .repetition import has_long_repetition
-from .xml_utils import extract_actual_text_from_xml
-
-
-bleu_metric: Any | None = None
-
-
-def initialize_metrics() -> None:
- """Lazily load BLEU metric and required NLTK resources."""
- global bleu_metric
- if not bleu_metric:
- import nltk
-
- nltk.download("wordnet", quiet=True)
- nltk.download("punkt_tab", quiet=True)
- nltk.download("omw-1.4", quiet=True)
-
- bleu_metric = evaluate.load("bleu")
-
-
-def levenshtein_distance(a: str, b: str, max_cost: int | None = None) -> int:
- """Compute Levenshtein distance with optional cutoff.
-
- Stops early if `max_cost` is exceeded (using RapidFuzz score cutoff).
- """
- if max_cost is not None:
- return rf_distance.Levenshtein.distance(a, b, score_cutoff=max_cost)
- return rf_distance.Levenshtein.distance(a, b)
-
-
-def evaluate_page(
- _input: tuple,
-) -> dict[str, Any]:
- """Evaluate predicted text vs. gold page returning a metrics dict."""
- example, predicted_text = _input
-
- metrics_dict = calculate_metrics((example, predicted_text))
-
- metrics_dict["file_name"] = example["file_name"]
- metrics_dict["predicted_text"] = predicted_text
- metrics_dict["gold_text"] = example["transcription"]
- metrics_dict["main_language"] = example["main_language"]
- metrics_dict["main_script"] = example["main_script"]
- metrics_dict["document_type"] = example["document_type"]
- metrics_dict["dataset_id"] = example["dataset_id"]
-
- return metrics_dict
-
-
-def _compute_text_metrics_core(
- predicted_text: str,
- gold_text: str,
- language: str,
- script: str,
-) -> dict[str, Any]:
- """Core text metric computation independent of ChurroExample objects.
-
- Args:
- predicted_text: The model predicted transcription (raw or XML).
- gold_text: The gold transcription (raw or XML).
- language: Primary language label (affects normalization rules).
- script: Primary script label.
-
- Returns:
- Dictionary containing evaluation metrics and normalized texts.
- """
- global bleu_metric
-
- bleu_result: float = 0.0
- normalized_levenshtein_similarity: float = 0.0
- has_repetition_flag: bool = False
- is_empty: float = 0.0
-
- try:
- predicted_text = extract_actual_text_from_xml(predicted_text)
- predicted_text = normalize_text_for_evaluation(
- predicted_text, normalize_arabic=(language in ["Arabic", "Persian"])
- )
- is_empty = 1.0 if not predicted_text.strip() else 0.0
-
- gold_text = extract_actual_text_from_xml(gold_text)
- gold_text_lines: list[str] = gold_text.splitlines()
- gold_text_lines = [normalize_text_for_evaluation(line) for line in gold_text_lines]
-
- gold_text = normalize_text_for_evaluation(
- gold_text, normalize_arabic=(language in ["Arabic", "Persian"])
- )
-
- denom: int = max(len(predicted_text), len(gold_text))
- if denom == 0:
- normalized_levenshtein_similarity = 1.0
- else:
- normalized_levenshtein_similarity = (
- 1 - levenshtein_distance(predicted_text, gold_text) / denom
- )
-
- has_repetition_flag = has_long_repetition(predicted_text)
-
- if is_empty == 1.0:
- bleu_result = 0.0
- else:
- bleu_result = bleu_metric.compute( # type: ignore
- predictions=[predicted_text],
- references=[[gold_text]],
- )["bleu"]
- except Exception as e:
- logger.error(f"Error in metric computation: {e}")
-
- return {
- "normalized_levenshtein_similarity": normalized_levenshtein_similarity,
- "repetition": float(has_repetition_flag),
- "is_empty": is_empty,
- "bleu": bleu_result,
- "normalized_predicted_text": predicted_text,
- "normalized_gold_text": gold_text,
- "main_language": language,
- "main_script": script,
- }
-
-
-def calculate_metrics_from_text(
- predicted_text: str,
- gold_text: str,
- language: str,
- script: str,
-) -> dict[str, Any]:
- """Evaluate metrics from raw predicted and gold text inputs."""
- return _compute_text_metrics_core(predicted_text, gold_text, language, script)
-
-
-def calculate_metrics(_input: tuple) -> dict[str, Any]:
- """Wrapper for multiprocessing: (ChurroExample, predicted_text) -> metrics."""
- example, predicted_text = _input
- try:
- metrics = _compute_text_metrics_core(
- predicted_text=predicted_text,
- gold_text=example["transcription"],
- language=example["main_language"],
- script=example["main_script"],
- )
- except Exception as e:
- logger.error(f"Error in evaluation of {example['file_name']}: {e}")
- metrics = {
- "normalized_levenshtein_similarity": 0.0,
- "repetition": 0.0,
- "is_empty": 1.0,
- "bleu": 0.0,
- "normalized_predicted_text": predicted_text,
- "normalized_gold_text": example.transcription,
- "main_language": example.main_language,
- "main_script": example.main_script,
- }
- return metrics
-
-
-def aggregate_results(
- results: list[dict[str, Any]],
-) -> tuple[dict[str, float], list[dict[str, Any]]]:
- """Average numeric metrics across page-level results (bools treated as 0/1)."""
- if not results:
- return {}, []
-
- # Initialize all numeric keys to 0.0
- aggregated_metrics: dict[str, float] = {}
- for k, v in results[0].items():
- if isinstance(v, int | float | bool):
- aggregated_metrics[k] = 0.0
-
- # Sum numeric values (treat bool as int -> 0/1)
- for m in results:
- for k, v in m.items():
- if isinstance(v, int | float | bool) and k in aggregated_metrics:
- aggregated_metrics[k] += float(v)
-
- # Average
- aggregated_metrics = {k: v / len(results) for k, v in aggregated_metrics.items()}
- return aggregated_metrics, results
-
-
-def batch_evaluate(
- dataset: list[dict[str, Any]],
- predicted_texts: list[str],
-) -> tuple[dict[str, float], list[dict[str, Any]]]:
- """Evaluate pages in parallel and return (aggregated_metrics, per_page)."""
- initialize_metrics()
- results = []
- with multiprocessing.Pool(processes=8) as pool:
- results = list(
- tqdm(
- pool.imap(evaluate_page, zip(dataset, predicted_texts, strict=False)),
- total=len(dataset),
- mininterval=0.5, # Update at most twice a second
- )
- )
-
- return aggregate_results(results)
diff --git a/evaluation/historical_doc.xsd b/evaluation/historical_doc.xsd
deleted file mode 100644
index 75a46c4..0000000
--- a/evaluation/historical_doc.xsd
+++ /dev/null
@@ -1,605 +0,0 @@
-
-
-
-
-
- Schema for encoding transcriptions of historical documents, incorporating structural elements, metadata, and detailed inline features.
-
-
-
-
-
- The root element for a single transcribed historical document.
-
-
-
-
-
-
-
-
-
-
-
- Contains metadata about the historical document and the transcription.
-
-
-
-
-
- The language(s) present in the document, using ISO 639 codes (e.g., 'eng', 'lat'). List all significant languages.
-
-
-
-
- The script(s) used in the document, using ISO 15924 codes (e.g., 'Latn', 'Cyrl', 'Arab'). List all significant scripts.
-
-
-
-
- The primary writing direction: ltr (left-to-right), rtl (right-to-left), ttb-ltr (top-to-bottom, left-to-right columns), ttb-rtl (top-to-bottom, right-to-left columns).
-
-
-
-
-
-
-
-
-
-
-
-
- Description of the physical object (material, dimensions, condition). E.g., 'Parchment, 1 folio, good condition with minor foxing'.
-
-
-
-
- A general description of the document content. E.g., 'Letter concerning trade routes'.
-
-
-
-
- Notes regarding the transcription process, conventions used, or difficulties encountered.
-
-
-
-
-
-
-
-
- Represents a single page (or folio side) of the document.
-
-
-
-
-
-
-
-
-
-
-
-
- Content typically found in the header area of a page.
-
-
-
-
-
-
-
-
-
-
-
-
- Content typically found in the footer area of a page.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- A page number marker.
-
-
-
-
- A folio number marker (numbering leaves instead of pages).
-
-
-
-
- The first word(s) of the next page, printed at the bottom of the current page.
-
-
-
-
- A mark (letter or number) indicating the gathering in early printed books.
-
-
-
-
-
- The main content area of the page.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- A paragraph of text.
-
-
-
-
-
-
-
-
-
-
- A note, typically commentary or reference, written in the margin of the page. Contrast with Addition, which represents text added by the original scribe as part of the composition.
-
-
-
-
-
-
-
- Specifies the margin where the note appears (left_margin, right_margin, top_margin, bottom_margin).
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- An inline note, typically commentary or reference, written between the lines. Contrast with Addition. Note that in top-to-bottom languages, interlinear notes appear differently.
-
-
-
-
-
-
-
-
-
-
- Represents tabular data.
-
-
-
-
-
-
-
-
-
-
- A row within a table.
-
-
-
-
-
-
-
-
-
-
- A cell within a table row.
-
-
-
-
-
-
-
- Specifies whether the cell acts as a header or data cell.
-
-
-
-
-
-
-
-
-
-
- Number of columns spanned by the cell.
-
-
-
-
- Number of rows spanned by the cell.
-
-
-
-
-
-
-
- A heading for a document section, chapter, entry, etc.
-
-
-
-
-
-
-
- Specifies the type or level of the heading (e.g., main heading, subheading, running title, heading of a figure, other).
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- A line containing a date, often at the start of a letter or entry.
-
-
-
-
-
-
-
-
- An entry associated with a specific date, like a diary entry.
-
-
-
-
-
-
-
-
-
-
-
- The normalized date of the entry (YYYY-MM-DD).
-
-
-
-
-
-
- An entry in a record book or log.
-
-
-
-
-
-
-
-
-
-
-
-
- The normalized date of the record (YYYY-MM-DD).
-
-
-
-
-
-
-
- A section of quoted text, often indented.
-
-
-
-
-
- Type of quotation (e.g., 'prose', 'verse').
-
-
-
-
-
-
-
- A list of items.
-
-
-
-
-
-
-
- Type of list (e.g., ordered, unordered, glossary).
-
-
-
-
-
-
-
- An item within a list.
-
-
-
-
-
-
-
-
- Represents a figure, illustration, diagram, chart, or other graphical element.
-
-
-
-
-
-
- A general textual description or representation of the figure/illustration's content. For content explicitly transcribed from the document, use Caption instead.
-
-
-
-
-
-
-
-
- A caption associated with an illustration or figure.
-
-
-
-
-
-
-
-
- Represents a seal (e.g., wax seal) on the document.
-
-
-
-
- Description of the seal.
-
-
-
-
-
-
-
- Represents a stamp on the document.
-
-
-
-
- Description or text of the stamp.
-
-
-
-
-
-
-
- Describes a watermark present in the paper.
-
-
-
-
- Description of the watermark.
-
-
-
-
-
-
-
- Represents a mathematical or chemical formula, assumed to be in LaTeX notation.
-
-
-
-
- Specifies the notation system used. Fixed to 'latex'.
-
-
-
-
-
-
-
- Represents musical notation.
-
-
-
-
- A description of the musical notation if not fully encoded.
-
-
-
-
-
-
-
- Represents a gap in the text (missing, illegible, or omitted). Can occur as a block or inline.
-
-
-
-
- The reason for the gap (illegible, missing, omitted, damaged, unknown).
-
-
-
-
-
-
-
-
-
-
-
-
-
- Estimated extent of the gap (e.g., 'ca. 5 letters', '1 line').
-
-
-
-
-
-
-
- Represents a single physical line of text from the source document. Contains text and inline elements.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Text written above the main line.
-
-
-
-
-
-
-
-
-
-
- An initial letter, potentially decorated and/or a drop capital.
-
-
-
-
- Specifies the type of initial letter (simple, decorated, drop, decorated-drop).
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Text that is emphasized in some way (italic, bold, underline, colored, etc.).
-
-
-
-
- The type of emphasis used (italic, bold, underline, colored, other).
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Marks a span of text that cannot be reliably transcribed. Use Deletion/Addition for overwritten text.
-
-
-
-
- The reason why the text is illegible (faded, damaged, blot, scribbled, binding).
-
-
-
-
-
-
-
-
-
-
-
-
-
- Estimated extent of the illegible text (e.g., 'ca. 3 words', '1 character').
-
-
-
-
-
-
-
- Text marked as deleted or struck out in the original. This represents content considered part of the text but marked as non-applicable.
-
-
-
-
-
-
- Text added to the original by the scribe, often interlinearly or marginally, as part of the composition or revision process. Contrast with MarginalNote and InterlinearNote which are typically commentary.
-
-
-
-
-
diff --git a/evaluation/metrics.py b/evaluation/metrics.py
deleted file mode 100644
index 47cf356..0000000
--- a/evaluation/metrics.py
+++ /dev/null
@@ -1,129 +0,0 @@
-from collections import defaultdict
-import json
-from typing import Any
-
-from churro.systems.detect_layout import get_total_azure_cost
-from churro.utils.llm.cost import get_llm_total_cost
-from churro.utils.log_utils import logger
-
-from .evaluate_page import batch_evaluate
-
-
-def to_rounded_percentage(metrics: dict[str, Any]) -> dict[str, Any]:
- """Round numeric metric values to one decimal percentage points."""
- return {
- key: round(value * 100) if isinstance(value, int | float) else value
- for key, value in metrics.items()
- }
-
-
-def round(value: float | int) -> float:
- """Round a float to one decimal place."""
- return float(f"{value:.1f}")
-
-
-def calculate_language_and_type_metrics(
- outputs: list[dict[str, Any]],
- main_metric: str = "normalized_levenshtein_similarity",
-) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
- """Compute averages grouped by language, type, and their combination."""
- main_language_to_metrics = defaultdict(list)
- main_language_and_type_to_metrics = defaultdict(list)
- type_to_metrics = {"print": [], "handwriting": []}
-
- for output in outputs:
- main_language = output["main_language"]
- metric = output[main_metric]
- document_type = output["document_type"]
-
- main_language_to_metrics[main_language].append(metric)
- type_to_metrics[document_type].append(metric)
- main_language_and_type_to_metrics[f"{main_language}_{document_type}"].append(metric)
-
- # Calculate averages
- averaged_main_language: dict[str, float] = {}
- for language, vals in main_language_to_metrics.items():
- averaged_main_language[language] = sum(vals) / len(vals) if vals else 0.0
-
- averaged_type: dict[str, float] = {}
- for doc_type, vals in type_to_metrics.items():
- averaged_type[doc_type] = sum(vals) / len(vals) if vals else 0.0
-
- averaged_lang_type: dict[str, float] = {}
- for lt, vals in main_language_and_type_to_metrics.items():
- averaged_lang_type[lt] = sum(vals) / len(vals) if vals else 0.0
- return averaged_main_language, averaged_type, averaged_lang_type
-
-
-def compute_metrics(
- dataset: list[dict],
- predicted_texts: list[str],
- output_prefix: str,
- elapsed_time: float,
- main_metric: str = "normalized_levenshtein_similarity",
-) -> dict[str, Any]:
- """Compute metrics, save per-example outputs, plots, and summary JSON."""
- # Ensure all predicted texts are strings
- for i in range(len(predicted_texts)):
- if not predicted_texts[i]:
- predicted_texts[i] = ""
-
- # Run evaluation
- aggregate_metrics, per_example_evaluation_outputs = batch_evaluate(dataset, predicted_texts)
-
- # Create outputs with evaluation results
- outputs = []
- for evaluation_output, example in zip(per_example_evaluation_outputs, dataset, strict=False):
- outputs.append(
- {
- "file_name": example["file_name"],
- **evaluation_output,
- }
- )
-
- # Save detailed outputs
- with open(f"{output_prefix}/outputs.json", "w") as f:
- json.dump(outputs, f, indent=2, ensure_ascii=False)
-
- # Calculate metrics by language and type
- language_metrics, type_metrics, lang_type_metrics = calculate_language_and_type_metrics(
- outputs, main_metric
- )
-
- # Round all metrics
- language_metrics = to_rounded_percentage(language_metrics)
- type_metrics = to_rounded_percentage(type_metrics)
- aggregate_metrics = to_rounded_percentage(aggregate_metrics)
- lang_type_metrics = to_rounded_percentage(lang_type_metrics)
- aggregate_metrics["llm_cost ($)"] = round(get_llm_total_cost()) # don't convert to percentage
- aggregate_metrics["azure_cost ($)"] = round(
- get_total_azure_cost()
- ) # don't convert to percentage
- aggregate_metrics["elapsed_time (s)"] = round(elapsed_time) # don't convert to percentage
-
- # Create combined metrics dictionary
- combined_metrics = {
- "main_language_metrics": language_metrics,
- "type_metrics": type_metrics,
- "aggregate_metrics": aggregate_metrics,
- "main_language_and_type_metrics": lang_type_metrics,
- }
-
- # Save all metrics
- with open(f"{output_prefix}/all_metrics.json", "w") as f:
- json.dump(combined_metrics, f, indent=2)
-
- # Log results
- logger.info("Average metrics per document type:")
- logger.info(json.dumps(type_metrics, indent=2))
-
- logger.info("Average metrics per main language:")
- logger.info(json.dumps(language_metrics, indent=2))
-
- logger.info("Average metrics per main language and type:")
- logger.info(json.dumps(lang_type_metrics, indent=2))
-
- logger.info("Aggregated metrics:")
- logger.info(json.dumps(aggregate_metrics, indent=2))
-
- return combined_metrics
diff --git a/evaluation/normalization.py b/evaluation/normalization.py
deleted file mode 100644
index 7fe77fe..0000000
--- a/evaluation/normalization.py
+++ /dev/null
@@ -1,146 +0,0 @@
-import re
-import unicodedata
-
-
-# TODO add more
-subs = {
- "\ueada": "st",
- "\ueec5": "ct",
- "\ueba6": "ss",
- "\ueba2": "si",
- "\ueba7": "ssi",
- "\ueba3": "sl",
- "’": "'",
- "¬": "-",
-}
-
-pattern = re.compile("|".join(map(re.escape, subs.keys())))
-
-
-def normalize_characters(text: str, keep_long_s: bool = True) -> str:
- """Replace special characters with ASCII equivalents.
-
- Inserts spaces before certain fractions, preserves long ſ if requested, and applies
- predefined glyph substitutions.
- """
- # Insert a space before common fraction characters when immediately preceded by a digit.
- text = re.sub(r"(?<=\d)(?=[↉½⅓¼⅕⅙⅐⅛⅑⅒⅔⅖¾⅗⅜⅘⅚⅞])", " ", text)
-
- # Placeholder for long s swap (always define to satisfy type checker)
- placeholder = "\ue000"
- if keep_long_s:
- # Use a placeholder unlikely to appear in normal text.
- text = text.replace("ſ", placeholder)
-
- # Normalize text to convert special fraction characters (e.g., '½') into their ASCII representations (e.g., '1/2')
- text = unicodedata.normalize("NFKC", text)
-
- if keep_long_s:
- text = text.replace(placeholder, "ſ")
-
- # Apply additional substitutions defined elsewhere (subs and pattern should be defined in the module)
- text = pattern.sub(lambda m: subs[m.group(0)], text)
-
- # Make this change to make the text stay close to the image
- # replace = at the end of a line with a ⸗
- # This is an example of https://en.wikipedia.org/wiki/Double_hyphen
- # This is used in for example newseye_finnish to indicate that a word was split across lines.
- # text = re.sub(r"=(?=\n|$)", "⸗", text)
-
- # Remove ~ at the beginning of a line. It is used in for example clarysse.
- text = re.sub(r"(^|\s)~(?=\w)", r"\1", text)
-
- return text
-
-
-def normalize_text_for_evaluation(
- text: str,
- normalize_arabic: bool = False,
-) -> str:
- """Normalize raw OCR/LLM text for evaluation.
-
- Applies case-folding, punctuation & markdown cleanup, dash standardization,
- removal of figure markers, whitespace collapsing, hyphen-merge, optional
- Arabic normalization, and character substitutions.
- """
- if normalize_arabic:
- from pyarabic.araby import (
- normalize_hamza,
- strip_harakat,
- strip_lastharaka,
- strip_tashkeel,
- strip_tatweel,
- )
-
- text = strip_tashkeel(text)
- text = strip_harakat(text)
- text = strip_lastharaka(text)
- text = strip_tatweel(text)
- text = normalize_hamza(text)
-
- # Convert to lowercase.
- text = text.lower()
-
- # Remove markdown symbols and standardize dashes.
- text = re.sub(r"[*_`~#]", "", text)
- text = re.sub(r"[–—−‑‒―‐]", "-", text)
-
- # Remove Markdown image blocks.
- text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
-
- # Remove lines that start with '[' and end with ']'. These are often what LLMs output as extra explanations.
- text = re.sub(r"^\s*\[.*\]\s*$", "", text, flags=re.MULTILINE)
-
- # Remove figure markers like "[figure 1]".
- text = re.sub(r"\[figure\s+\d+\]", "", text)
-
- # Remove blockquote markers at the start of lines.
- text = re.sub(r"^>\s+", "", text, flags=re.MULTILINE)
-
- # Remove sequences of three or more hyphens.
- text = re.sub(r"-{3,}", "", text)
-
- # Remove spaces before punctuation.
- text = re.sub(r"\s+([.,?!;:])", r"\1", text)
-
- # Merge words split by newline hyphenation.
- text = re.sub(r"(\w+)-\s*\n\s*(\w+)", r"\1\2", text)
- text = text.strip("-")
-
- text = normalize_characters(text, keep_long_s=False)
-
- # Collapse multiple whitespace characters into a single space.
- text = re.sub(r"\s+", " ", text).strip()
-
- return text
-
-
-def remove_transcription_tags(text: str) -> str:
- """Remove specialized editorial/marker tags and abbreviation constructs."""
- # Step 1: Convert abbreviated expressions like "Adm.$^r$.Administrador" to "Admr"
- # Pattern:
- # Capture the base abbreviation, a literal period, a marker ($^letters$), another literal period,
- # then the full word (which we ignore)
- # print("input text:", text)
- pattern_abbrev = r"\b([A-Za-z]+)\.\$\^([A-Za-z]+)\$\.[A-Za-z]+"
- text = re.sub(pattern_abbrev, r"\1\2", text)
-
- # Step 2: Convert expressions like "dho$.dicho" to "dho"
- # Pattern explanation:
- # \b([A-Za-z]+) : capture a word before the marker (e.g., "dho")
- # \$\. : literal "$." sequence
- # [A-Za-z]+ : one or more letters (e.g., "dicho")
- pattern_dho = r"\b([A-Za-z]+)\$\.[A-Za-z]+"
- text = re.sub(pattern_dho, r"\1", text)
-
- # Step 3: Remove tags of the form "$tag:" (e.g. "$ant:", "$ofi:", etc.)
- text = re.sub(r"\$[A-Za-z]+:", "", text)
-
- # Remove markers enclosed in $ signs, e.g. "$^r$" or "$dho$"
- text = re.sub(r"\$[\^A-Za-z]+\$", "", text)
-
- # text = text.replace("$-", "-")
- text = text.replace(":$-\n$-", "-\n")
- # print("changed text: ", text)
-
- return text
diff --git a/evaluation/repetition.py b/evaluation/repetition.py
deleted file mode 100644
index af3c56e..0000000
--- a/evaluation/repetition.py
+++ /dev/null
@@ -1,42 +0,0 @@
-def has_long_repetition(s: str) -> bool:
- """Return True iff s = A + B*k (k>=2) with nonempty A,B and len(A) <= 0.8*len(s)."""
- n: int = len(s)
- if n < 2:
- return False
-
- # compute prefix‐function on reversed string
- r: str = s[::-1]
- pi: list[int] = [0] * n
- for i in range(1, n):
- j: int = pi[i - 1]
- while j and r[i] != r[j]:
- j = pi[j - 1]
- if r[i] == r[j]:
- j += 1
- pi[i] = j
-
- max_a: int = int(0.8 * n)
- for a in range(1, max_a + 1):
- rem: int = n - a
- if rem < 2:
- continue
- border: int = pi[rem - 1]
- p: int = rem - border
- # B must repeat at least twice
- if border > 0 and rem % p == 0 and rem // p >= 2:
- return True
-
- return False
-
-
-# def remove_repetition(text: str) -> str:
-# """
-# Remove long repetitions from the text.
-# """
-# if not text:
-# return text
-# result, count = longest_repeated_substring_nonoverlapping(text, 10)
-# ratio = len(result) * count / len(text)
-# if ratio > 0.1:
-# return text.replace(result, "")
-# return text
diff --git a/evaluation/xml_utils.py b/evaluation/xml_utils.py
deleted file mode 100644
index dd618a4..0000000
--- a/evaluation/xml_utils.py
+++ /dev/null
@@ -1,151 +0,0 @@
-from pathlib import Path
-import re
-import xml.etree.ElementTree as ET
-
-from lxml import etree # type: ignore
-import xmlschema
-
-from churro.utils.log_utils import logger
-
-
-historical_doc_schema = None
-allowed_xml_pattern = None
-SCHEMA_PATH = Path(__file__).resolve().parent / "historical_doc.xsd"
-
-
-def _escape_chunk(text: str) -> str:
- # only escape &, <, >
- return text.replace("&", "&").replace("<", "<").replace(">", ">")
-
-
-def _escape_xml(s: str) -> str:
- """Escape ``<``, ``>``, ``&`` except inside allowed XML tags/structures."""
- global allowed_xml_pattern
- # Compile the pattern once and stash it on the function
- if allowed_xml_pattern is None:
- tags = _get_list_of_valid_xml_tags()
- # build a regex that matches:
- # , , , , etc.
- tag_alts = "|".join(re.escape(t) for t in tags)
- allowed_xml_pattern = re.compile(
- rf"(<\?xml.*?\?>|?(?:{tag_alts})(?:\b[^>]*?)?/?>)", re.DOTALL
- )
-
- pat = allowed_xml_pattern
- parts = []
- last = 0
-
- # Iterate through all matches of allowed XML fragments
- for m in pat.finditer(s):
- if m.start() > last:
- parts.append(_escape_chunk(s[last : m.start()]))
- parts.append(m.group(0))
- last = m.end()
-
- # escape any remaining tail
- if last < len(s):
- parts.append(_escape_chunk(s[last:]))
-
- return "".join(parts)
-
-
-def _get_list_of_valid_xml_tags() -> list[str]:
- """Return valid XML tags for the historical document schema."""
- global historical_doc_schema
- if historical_doc_schema is None:
- historical_doc_schema = xmlschema.XMLSchema(str(SCHEMA_PATH))
- return list(historical_doc_schema.elements.keys()) + [
- "PhysicalDescription",
- "Language",
- "Script",
- "PhysicalDescription",
- "Description",
- "WritingDirection",
- "TranscriptionNote",
- "Footer",
- "Header",
- ]
-
-
-def extract_actual_text_from_xml(xml_content: str) -> str:
- """Extract concatenated text from ````, ````, ```` of each page."""
- if "HistoricalDocument" not in xml_content:
- return xml_content
-
- xml_content = _escape_xml(xml_content)
- try:
- xml_content = _remove_tag(
- xml_content, "Description"
- ) # ignore description tags, e.g. in Figure, Stamp, Seal tags etc.
- xml_content = _remove_tag(xml_content, "Deletion")
- xml_content = _remove_tag(xml_content, "Illegible")
- xml_content = _remove_tag(xml_content, "Gap")
-
- parser = etree.XMLParser(recover=True)
- root = etree.fromstring(xml_content.encode("utf-8"), parser)
-
- # Build namespace map & prefix for XPath
- default_ns: str = root.nsmap.get(None) or ""
- xpath_nsmap: dict[str, str] = {}
- prefix: str = ""
- if default_ns:
- prefix = "docns"
- xpath_nsmap[prefix] = default_ns
-
- # Find all elements in document order
- page_query = f".//{prefix + ':' if prefix else ''}Page"
- pages: list[etree._Element] = root.xpath(page_query, namespaces=xpath_nsmap)
-
- text_parts_per_page: list[str] = []
- tags_to_extract: list[str] = ["Header", "Body", "Footer"]
-
- for page in pages:
- page_text_parts: list[str] = []
- for tag_name in tags_to_extract:
- qname = f"{prefix + ':' if prefix else ''}{tag_name}"
- elements: list[etree._Element] = page.xpath(f".//{qname}", namespaces=xpath_nsmap)
- for elem in elements:
- # collect all text inside the element
- lines: list[str] = []
- for t_node in elem.itertext():
- cleaned = re.sub(
- r"<(/)?(lb|br)\s*/?>", "", t_node, flags=re.IGNORECASE
- ).strip()
- if cleaned:
- lines.append(cleaned)
- if lines:
- page_text_parts.append("\n".join(lines))
-
- # join this page's parts if any
- if page_text_parts:
- text_parts_per_page.append("\n".join(page_text_parts))
-
- if not text_parts_per_page:
- logger.warning("No text found in any /,, tags.")
- return ""
-
- full_text = "\n\n".join(text_parts_per_page)
- # cleanup stray
or in text and collapse multiple blank lines
- full_text = re.sub(r"<(/)?(lb|br)\s*/?>", "", full_text, flags=re.IGNORECASE)
- full_text = re.sub(r"\n\s*\n+", "\n\n", full_text)
- return full_text.strip()
-
- except (etree.XMLSyntaxError, ET.ParseError) as e:
- logger.error(f"Failed to parse XML content: {e}")
- return ""
- except Exception as e:
- logger.exception(f"Unexpected error during text extraction: {e}")
- return ""
-
-
-def _remove_tag(xml_content: str, tag_name: str) -> str:
- import re
-
- # Only process if tag present
- if f"<{tag_name}" not in xml_content:
- return xml_content
- # Remove opening to closing tags with any content in between
- xml_content = re.sub(rf"<{tag_name}\b[^>]*>.*?{tag_name}>", "", xml_content, flags=re.DOTALL)
- # Remove self-closing tags
- xml_content = re.sub(rf"<{tag_name}\b[^>]*/>", "", xml_content)
- return xml_content
diff --git a/page/__init__.py b/page/__init__.py
deleted file mode 100644
index bb23309..0000000
--- a/page/__init__.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from .page import Page
-from .page_object import PageObject
-from .visualization import (
- crop_page_objects_from_image,
- extract_polygon_region,
-)
-
-
-__all__ = [
- "PageObject",
- "Page",
- "extract_polygon_region",
- "crop_page_objects_from_image",
-]
diff --git a/page/page.py b/page/page.py
deleted file mode 100644
index 5659ac4..0000000
--- a/page/page.py
+++ /dev/null
@@ -1,246 +0,0 @@
-"""Core page abstractions and helpers for OCR layout post-processing."""
-
-from collections import defaultdict
-import math
-
-from azure.ai.documentintelligence.models import AnalyzeResult
-from pydantic import BaseModel, ConfigDict, Field
-import rtree
-
-from churro.utils.log_utils import logger
-
-from .page_object import PageObject
-
-
-class Page(BaseModel):
- """Represents a single OCR page with its objects, reading order, and metadata.
-
- This model stores detected layout elements (`page_objects`), optional reading order
- (as a directed graph), raw/full text, and auxiliary metadata such as languages.
- Utility methods provide convenience accessors for path-derived attributes and
- transformations (filtering, merging, saving, etc.).
- """
-
- page_objects: list[PageObject] = Field(..., description="List of objects on the page")
-
- model_config = ConfigDict(arbitrary_types_allowed=True)
-
- @staticmethod
- def from_azure_analysis_result(result: AnalyzeResult, skip_paragraphs: bool = False) -> "Page":
- """Build a `Page` instance from an Azure Document Intelligence result."""
- page_objects = []
-
- object_id = 1
-
- paragraph_spans = [] # each span is (offset, length)
- if result.paragraphs and not skip_paragraphs:
- for paragraph in result.paragraphs:
- if not paragraph.bounding_regions or len(paragraph.bounding_regions) != 1:
- continue
- for span in paragraph.spans or []:
- paragraph_spans.append((span.offset, span.length))
- polygon_coords = paragraph.bounding_regions[0].polygon or []
- if not polygon_coords:
- continue
- page_objects.append(
- PageObject(
- object_id=str(object_id),
- coordinates=polygon_coords,
- )
- )
- object_id += 1
- if result.figures:
- for figure in result.figures:
- if not figure.bounding_regions or len(figure.bounding_regions) != 1:
- continue
- fig_coords = figure.bounding_regions[0].polygon or []
- if not fig_coords:
- continue
- page_objects.append(
- PageObject(
- object_id=str(object_id),
- coordinates=fig_coords,
- )
- )
- object_id += 1
-
- assert len(result.pages) == 1, "Expected only one page"
- p = result.pages[0]
-
- # use spans to determine which lines are already included in paragraphs
- lines_added = 0
- for line in p.lines or []:
- for span in line.spans:
- # check if span fully falls within a paragraph
- found = False
- for offset, length in paragraph_spans:
- if span.offset >= offset and span.offset + span.length <= offset + length:
- found = True
- break
- if not found:
- lines_added += 1
- if line.polygon:
- page_objects.append(
- PageObject(
- object_id=str(object_id),
- coordinates=line.polygon,
- )
- )
- object_id += 1
-
- # page objects with weird angles are often mistakes. We remove them here so that they can be added as their individual lines below
- old_size = len(page_objects)
- page_objects = [po for po in page_objects if po.get_top_edge_angle() <= 10]
- if old_size - len(page_objects) > 0:
- logger.info(f"Removed {old_size - len(page_objects)} page objects with weird angles")
-
- if lines_added > 0:
- logger.info(f"Added {lines_added} lines that were not part of any paragraphs to page")
-
- page = Page(
- page_objects=page_objects,
- )
- return page
-
- def remove_subsumed_page_objects(self, coverage_ratio: float = 0.7) -> None:
- """Remove page_objects that are subsumed by other page_objects using an R-tree for efficient spatial queries.
-
- When coverage_ratio is 100.0 (default), an object is removed if it is fully subsumed (using the default
- PageObject.remove_subsumed_objects logic). If a lower ratio is provided, an object is removed if at least
- that ratio of its area is covered by any other page object.
-
- Args:
- coverage_ratio (float): The coverage ratio for determining if a page object is subsumed by another. A value of 0.7 means
- that a page object is considered subsumed if 70% of its area is covered by another page object.
- """
- if not self.page_objects:
- return
- assert 0 <= coverage_ratio <= 1, "coverage_ratio must be between 0 and 1"
-
- # Record (index, page object) pairs.
- enumerated_polygons = [(i, obj) for i, obj in enumerate(self.page_objects)]
- page_objects = [p for _, p in enumerated_polygons]
-
- # Remove subsumed polygons; returns a list of polygon objects in the original order.
- polygons_to_keep = PageObject.remove_subsumed_objects(
- page_objects, tolerance=1 - coverage_ratio
- )
-
- # Map each polygon object back to its original indices.
- polygon_to_indices = defaultdict(list)
- for i, poly in enumerated_polygons:
- polygon_to_indices[poly].append(i)
-
- kept_indices = []
- for poly in polygons_to_keep:
- # Pop the first stored index for this polygon in case of duplicates.
- kept_indices.append(polygon_to_indices[poly].pop(0))
- kept_indices.sort()
- self.page_objects = [self.page_objects[i] for i in kept_indices]
-
- def remove_small_page_objects_in_margins(self) -> None:
- """Drop tiny page objects hugging the margins to reduce noise."""
- sample_count = len(self.page_objects)
- if sample_count < 2:
- return
-
- index = rtree.index.Index()
- # Spatial index accelerates intersection checks against margin bands.
- bounds: list[tuple[float, float, float, float]] = []
- widths: list[float] = []
- heights: list[float] = []
- for idx, obj in enumerate(self.page_objects):
- left, top, right, bottom = obj.bounds
- bounds.append((left, top, right, bottom))
- widths.append(obj.width)
- heights.append(obj.height)
- index.insert(idx, (left, top, right, bottom))
-
- smallest_count = max(1, math.ceil(sample_count * 0.1))
- smallest_width_indices = set(
- sorted(range(sample_count), key=lambda i: widths[i])[:smallest_count]
- )
- smallest_height_indices = set(
- sorted(range(sample_count), key=lambda i: heights[i])[:smallest_count]
- )
-
- global_left = min(b[0] for b in bounds)
- global_top = min(b[1] for b in bounds)
- global_right = max(b[2] for b in bounds)
- global_bottom = max(b[3] for b in bounds)
-
- page_width = max(global_right - global_left, 1.0)
- page_height = max(global_bottom - global_top, 1.0)
- x_tolerance = max(5.0, page_width * 0.02)
- y_tolerance = max(5.0, page_height * 0.02)
-
- def margin_rectangles() -> list[tuple[float, float, float, float]]:
- return [
- (
- max(global_left, global_right - x_tolerance),
- global_top,
- global_right,
- global_bottom,
- ),
- (
- global_left,
- global_top,
- min(global_right, global_left + x_tolerance),
- global_bottom,
- ),
- (
- global_left,
- global_top,
- global_right,
- min(global_bottom, global_top + y_tolerance),
- ),
- (
- global_left,
- max(global_top, global_bottom - y_tolerance),
- global_right,
- global_bottom,
- ),
- ]
-
- def is_on_margin(obj_bounds: tuple[float, float, float, float]) -> bool:
- left, top, right, bottom = obj_bounds
- return (
- (left - global_left) <= x_tolerance
- or (global_right - right) <= x_tolerance
- or (top - global_top) <= y_tolerance
- or (global_bottom - bottom) <= y_tolerance
- )
-
- candidate_indices: set[int] = set()
- for rect in margin_rectangles():
- if rect[0] > rect[2] or rect[1] > rect[3]:
- continue
- candidate_indices.update(index.intersection(rect))
-
- indices_to_remove = {
- idx
- for idx in candidate_indices
- if idx in smallest_width_indices
- and idx in smallest_height_indices
- and is_on_margin(bounds[idx])
- }
- # Keep only elements that are both tiny and sit within a margin band.
-
- if not indices_to_remove:
- return
-
- if len(indices_to_remove) == sample_count:
- candidate_to_keep = max(
- indices_to_remove,
- key=lambda idx_: widths[idx_] * heights[idx_],
- )
- indices_to_remove.remove(candidate_to_keep)
- if not indices_to_remove:
- return
-
- removed_count = len(indices_to_remove)
- self.page_objects = [
- obj for idx, obj in enumerate(self.page_objects) if idx not in indices_to_remove
- ]
-
- logger.info(f"Removed {removed_count} small margin page objects out of {sample_count}")
diff --git a/page/page_object.py b/page/page_object.py
deleted file mode 100644
index 4263f62..0000000
--- a/page/page_object.py
+++ /dev/null
@@ -1,240 +0,0 @@
-from __future__ import annotations
-
-import numpy as np
-from pydantic import BaseModel, ConfigDict, PrivateAttr, model_validator
-import rtree
-from shapely.affinity import translate
-from shapely.geometry import Polygon as ShapelyPolygon
-
-from churro.utils.log_utils import logger
-
-
-def _rotate_point(
- px: float, py: float, angle: float, center: tuple[float, float]
-) -> tuple[float, float]:
- """Rotate (px, py) around center by angle degrees."""
- angle_rad = np.deg2rad(angle)
- cos_a = np.cos(angle_rad)
- sin_a = np.sin(angle_rad)
- ox, oy = center
- dx = px - ox
- dy = py - oy
- qx = ox + cos_a * dx - sin_a * dy
- qy = oy + sin_a * dx + cos_a * dy
- return qx, qy
-
-
-class PageObject(BaseModel):
- object_id: str
- coordinates: list[float]
- text: str | None = None
-
- model_config = ConfigDict(
- extra="forbid", arbitrary_types_allowed=True, validate_assignment=True
- )
-
- _shapely_polygon: ShapelyPolygon = PrivateAttr()
-
- @model_validator(mode="before")
- @classmethod
- def convert_llm_ocr_text(cls, data: dict[str, object]) -> dict[str, object]:
- if isinstance(data, dict) and data.get("text") is None and "llm_ocr_text" in data:
- data["text"] = data.pop("llm_ocr_text")
- return data
-
- @model_validator(mode="after")
- def initialize_polygon(self) -> PageObject:
- self._set_polygon(self.coordinates)
- return self
-
- def _set_polygon(self, coordinates: list[float]) -> None:
- if not coordinates or len(coordinates) % 2 != 0:
- raise ValueError("Coordinates must contain an even number of values.")
- points = [
- (float(coordinates[i]), float(coordinates[i + 1]))
- for i in range(0, len(coordinates), 2)
- ]
- if points[0] != points[-1]:
- points.append(points[0])
-
- poly = ShapelyPolygon(shell=points)
- # Round coordinates for consistency
- int_coords = [(int(round(x)), int(round(y))) for x, y in poly.exterior.coords]
- poly = ShapelyPolygon(shell=int_coords)
-
- self._shapely_polygon = poly
- flattened = [coord for point in poly.exterior.coords for coord in point]
- object.__setattr__(self, "coordinates", flattened)
-
- def update_coordinates(self, coordinates: list[float]) -> None:
- """Replace the polygon coordinates and refresh the cached Shapely polygon."""
- self._set_polygon(coordinates)
-
- @property
- def width(self) -> float:
- minx, _, maxx, _ = self._shapely_polygon.bounds
- return maxx - minx
-
- @property
- def height(self) -> float:
- _, miny, _, maxy = self._shapely_polygon.bounds
- return maxy - miny
-
- @property
- def left(self) -> float:
- return self._shapely_polygon.bounds[0]
-
- @property
- def right(self) -> float:
- return self._shapely_polygon.bounds[2]
-
- @property
- def top(self) -> float:
- return self._shapely_polygon.bounds[1]
-
- @property
- def bottom(self) -> float:
- return self._shapely_polygon.bounds[3]
-
- @property
- def bounds(self) -> tuple[float, float, float, float]:
- return self.left, self.top, self.right, self.bottom
-
- @property
- def area(self) -> float:
- return self._shapely_polygon.area
-
- def get_top_edge_angle(self) -> float:
- coords = list(self._shapely_polygon.exterior.coords)
- edges = zip(coords, coords[1:], strict=False)
- top_edge = max(edges, key=lambda edge: (edge[0][1] + edge[1][1]) / 2)
- (x1, y1), (x2, y2) = top_edge
- angle = np.rad2deg(np.arctan2(y2 - y1, x2 - x1))
- angle = angle % 90
- return min(angle, 90 - angle)
-
- def contains(self, other: PageObject, tolerance: float = 0.05) -> bool:
- intersection = self._shapely_polygon.intersection(other._shapely_polygon)
- outside_area = other._shapely_polygon.area - intersection.area
- if other._shapely_polygon.area == 0:
- return False
- return (outside_area / other._shapely_polygon.area) <= tolerance
-
- def rotate(
- self,
- angle: float,
- center: tuple[float, float],
- offset_x: float,
- offset_y: float,
- ) -> None:
- rotated = (
- _rotate_point(x, y, angle, center)
- for x, y in zip(self.coordinates[::2], self.coordinates[1::2], strict=False)
- )
- coords = [
- coord + offset
- for point in rotated
- for coord, offset in zip(point, (offset_x, offset_y), strict=False)
- ]
- self._set_polygon(coords)
-
- def relative_coordinates(self) -> list[tuple[float, float]]:
- """Return polygon coordinates translated to the bounding box origin."""
- shift_x = self.left
- shift_y = self.top
- shifted = translate(self._shapely_polygon, xoff=-shift_x, yoff=-shift_y)
- return [(int(round(x)), int(round(y))) for x, y in shifted.exterior.coords]
-
- @staticmethod
- def all_encompassing_rectangle(
- page_objects: list[PageObject],
- object_id: str | None = None,
- ) -> PageObject:
- """Return a rectangle covering all provided page objects.
-
- Useful for creating smaller, more focused visualizations.
- """
- assert page_objects, "Cannot create an all-encompassing rectangle with no page objects"
-
- minx, miny, maxx, maxy = (
- page_objects[0].left,
- page_objects[0].top,
- page_objects[0].right,
- page_objects[0].bottom,
- )
-
- for obj in page_objects[1:]:
- b_left, b_top, b_right, b_bottom = obj.left, obj.top, obj.right, obj.bottom
- minx, miny = min(minx, b_left), min(miny, b_top)
- maxx, maxy = max(maxx, b_right), max(maxy, b_bottom)
-
- coords = [minx, miny, maxx, miny, maxx, maxy, minx, maxy]
- rect_id = object_id or f"{page_objects[0].object_id}-encompassing"
- return PageObject(object_id=rect_id, coordinates=coords)
-
- @staticmethod
- def from_bounds(
- left: float,
- top: float,
- right: float,
- bottom: float,
- object_id: str = "bounds",
- text: str | None = None,
- ) -> PageObject:
- coords = [left, top, right, top, right, bottom, left, bottom]
- return PageObject(object_id=object_id, coordinates=coords, text=text)
-
- @staticmethod
- def remove_subsumed_objects(
- page_objects: list[PageObject], tolerance: float = 0.2
- ) -> list[PageObject]:
- if not page_objects:
- return page_objects
-
- indexed_page_objects = sorted(
- enumerate(page_objects), key=lambda pair: pair[1].area, reverse=True
- )
-
- idx = rtree.index.Index()
- page_objects_to_keep: list[tuple[int, PageObject]] = []
-
- for original_idx, page_object in indexed_page_objects:
- bounds = page_object.bounds
- potential_subsumers = list(idx.intersection(bounds))
-
- for sub_idx in potential_subsumers:
- _, other_page_object = page_objects_to_keep[sub_idx]
- if other_page_object.contains(page_object, tolerance=tolerance):
- break
- else:
- page_objects_to_keep.append((original_idx, page_object))
- idx.insert(len(page_objects_to_keep) - 1, bounds)
-
- removed_count = len(page_objects) - len(page_objects_to_keep)
- if removed_count > 0:
- logger.info(f"Removed {removed_count} subsumed page objects out of {len(page_objects)}")
-
- page_objects_to_keep.sort(key=lambda pair: pair[0])
-
- return [page_object for _, page_object in page_objects_to_keep]
-
- def __hash__(self) -> int:
- """Return a stable hash for comparing page objects in sets and dicts."""
- return hash((self.object_id, tuple(self.coordinates), self.text))
-
- def __eq__(self, other: object) -> bool:
- """Return True if the other page object matches by id, text, and coordinates."""
- if not isinstance(other, PageObject):
- return False
- return (
- self.object_id == other.object_id
- and self.text == other.text
- and tuple(self.coordinates) == tuple(other.coordinates)
- )
-
- def __repr__(self) -> str:
- """Return a concise string representation for debugging the page object."""
- return (
- f"PageObject(id={self.object_id}, left={self.left}, top={self.top}, "
- f"right={self.right}, bottom={self.bottom})"
- )
diff --git a/page/visualization.py b/page/visualization.py
deleted file mode 100644
index 4732165..0000000
--- a/page/visualization.py
+++ /dev/null
@@ -1,91 +0,0 @@
-from PIL import Image, ImageDraw
-
-from .page_object import PageObject
-
-
-def extract_polygon_region(
- image: Image.Image,
- page_object: PageObject,
-) -> Image.Image:
- """Crop a PIL image using a PageObject's polygon.
-
- Parameters:
- image (PIL.Image.Image): The input image to crop.
- page_object (PageObject): The polygon defining the crop area.
- background_color (tuple[int, int, int]): The background color to use for areas outside the polygon.
-
- Returns:
- PIL.Image.Image: The cropped image with the polygon applied as a mask.
- """
- # Extract bounding box dimensions
- width = int(page_object.width)
- height = int(page_object.height)
- min_x = int(page_object.left)
- min_y = int(page_object.top)
-
- # Create a mask image with the same size as the bounding box
- mask = Image.new("L", (width, height), 0)
-
- # Adjust polygon coordinates relative to the bounding box
- relative_points = page_object.relative_coordinates()
-
- # Draw the polygon on the mask
- mask_draw = ImageDraw.Draw(mask)
- mask_draw.polygon(relative_points, fill=255)
-
- # Crop the original image to the bounding box
- cropped_image = image.crop((min_x, min_y, min_x + width, min_y + height))
-
- # Create a new image
- result_image = Image.new("RGB", (width, height), color=(255, 255, 255)) # type: ignore
-
- # Paste the cropped image onto the result image using the mask
- result_image.paste(cropped_image, (0, 0), mask)
-
- return result_image
-
-
-def crop_page_objects_from_image(
- page_objects: list[PageObject],
- original_image: Image.Image,
-) -> list[Image.Image]:
- cropped_images = []
-
- for page_object in page_objects:
- cropped_img = extract_polygon_region(
- original_image,
- page_object,
- )
-
- cropped_images.append(cropped_img)
-
- return cropped_images
-
-
-def crop_image_to_objects(
- image: Image.Image, page_objects: list[PageObject], margin: int = 0
-) -> Image.Image:
- """Remove empty margins around page content.
-
- The function detects the minimal bounding rectangle over all page objects and
- crops the image to it, optionally expanding by ``margin``.
- """
- if margin < 0:
- raise ValueError("Margin must be a non-negative integer.")
-
- polygon = PageObject.all_encompassing_rectangle(page_objects)
- if margin > 0:
- left, top, right, bottom = polygon.bounds
- left = max(left - margin, 0)
- top = max(top - margin, 0)
- right = min(right + margin, image.width)
- bottom = min(bottom + margin, image.height)
- polygon = PageObject.from_bounds(
- left,
- top,
- right,
- bottom,
- object_id=f"{polygon.object_id}-margin",
- )
-
- return extract_polygon_region(image, polygon)
diff --git a/page_boundary/__init__.py b/page_boundary/__init__.py
deleted file mode 100644
index bab9535..0000000
--- a/page_boundary/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Public interfaces for Gemini page boundary detection."""
-
-from .detector import GeminiPageBoundaryDetector, run_page_detection
-
-
-__all__ = [
- "GeminiPageBoundaryDetector",
- "run_page_detection",
-]
diff --git a/page_boundary/_constants.py b/page_boundary/_constants.py
deleted file mode 100644
index 503e1bc..0000000
--- a/page_boundary/_constants.py
+++ /dev/null
@@ -1,73 +0,0 @@
-"""Shared constants for Gemini page boundary detection."""
-
-from __future__ import annotations
-
-
-PAGE_RESPONSE_INSTRUCTIONS = """- Return a JSON object with a key "pages".
-- "pages" must be a list containing zero or more objects.
-- Each object must have:
- * "page_index": 1-based index in reading order.
- * "left": integer normalized 0-1000 describing the minimum horizontal coordinate.
- * "top": integer normalized 0-1000 describing the minimum vertical coordinate.
- * "right": integer normalized 0-1000 describing the maximum horizontal coordinate.
- * "bottom": integer normalized 0-1000 describing the maximum vertical coordinate.
-- Provide all coordinates as integers (no decimals) and keep them normalized to the
- 0-1000 range.
-- The box should cover every part of the physical page contents, including all printed
- text, handwriting, stamps, or drawings that belong on the page surface.
-- Bounding boxes must cover the entire page content, excluding empty margins.
-- Do not include printer artifacts, scanner noise, shadows, binding rings, or any
- background beyond the true page edges.
-- If no page is visible, return {"pages": []}.
-"""
-
-PAGE_DETECTION_PROMPT = (
- "You are a careful vision assistant that locates the page content inside\n"
- "scanned or photographed document images. Return tight bounding boxes around each\n"
- "page (there will be either one page or a two-page spread). Ensure coordinates are\n"
- "precise.\n\n"
- "Identify every document page in this image. Follow these rules:\n"
- f"{PAGE_RESPONSE_INSTRUCTIONS}"
-)
-
-PAGE_REVIEW_PROMPT_TEMPLATE = (
- "You are an expert reviewer of document page boundary annotations. Evaluate the provided\n"
- "image with red rectangles drawn from the most recent prediction and output corrected boxes\n"
- "in the same JSON structure when adjustments are needed.\n\n"
- "Only the annotated preview image is supplied, so rely on the drawn rectangles and the\n"
- "visible page content to decide whether corrections are necessary.\n\n"
- "The currently drawn bounding boxes are summarized with lines such as\n"
- '"left : 412" for each page. Use them as context, but respond with a fresh JSON object that\n'
- "follows the required schema.\n\n"
- "Inspect the red rectangles and correct them if they are inaccurate or incomplete. Think about how much they should be moved to better fit the page content.\n"
- "Respond with JSON following these rules:\n"
- f"{PAGE_RESPONSE_INSTRUCTIONS}"
- "If the rectangles are already correct, you may return the same coordinates."
-)
-
-PAGE_DETECTION_BOX_WIDTH = 10
-MAX_PAGE_REVIEW_ROUNDS = 4
-BORDER_FRACTION = 0.10
-PROCESSED_MAX_DIM = 1500
-GUIDELINE_COLOR = "#ff3b30"
-DEFAULT_MODEL_KEY = "gemini-2.5-pro-high"
-DAMPENING_CONSTANT = 1
-
-_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
-
-__all__ = [
- "PAGE_RESPONSE_INSTRUCTIONS",
- "PAGE_DETECTION_PROMPT",
- "PAGE_REVIEW_PROMPT_TEMPLATE",
- "PAGE_DETECTION_BOX_WIDTH",
- "MAX_PAGE_REVIEW_ROUNDS",
- "BORDER_FRACTION",
- "DAMPENING_CONSTANT",
- "PROCESSED_MAX_DIM",
- "GUIDELINE_COLOR",
- "DEFAULT_MODEL_KEY",
- "NORMALIZED_MIN_COORD",
- "NORMALIZED_MAX_COORD",
-]
diff --git a/page_boundary/_image_processing.py b/page_boundary/_image_processing.py
deleted file mode 100644
index c271f64..0000000
--- a/page_boundary/_image_processing.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""Image preprocessing helpers for Gemini page detection."""
-
-from __future__ import annotations
-
-from collections.abc import Iterable
-
-from PIL import Image, ImageDraw, ImageOps
-
-from churro.page import PageObject, extract_polygon_region
-from churro.utils.image.transform import adjust_image, resize_image_to_fit
-
-from ._constants import (
- BORDER_FRACTION,
- GUIDELINE_COLOR,
- PAGE_DETECTION_BOX_WIDTH,
- PROCESSED_MAX_DIM,
-)
-from ._models import PageBox, PageDetectionTransform
-
-
-def _add_white_border(
- image: Image.Image, fraction: float = BORDER_FRACTION
-) -> tuple[Image.Image, int, int]:
- """Add a white border around the image proportional to the input size."""
- if fraction <= 0:
- return image, 0, 0
- border_w = max(1, int(round(image.width * fraction)))
- border_h = max(1, int(round(image.height * fraction)))
- expanded = ImageOps.expand(
- image,
- border=(border_w, border_h, border_w, border_h),
- fill="white",
- )
- return expanded, border_w, border_h
-
-
-def prepare_page_image(image: Image.Image) -> tuple[Image.Image, PageDetectionTransform]:
- """Normalize an input image so Gemini receives a padded and size-limited RGB copy."""
- original_size = image.size
- rgb_image = image.convert("RGB")
- grayscale = adjust_image(rgb_image, thresholding=True)
- padded, border_w, border_h = _add_white_border(grayscale)
- padded_size = padded.size
- processed = resize_image_to_fit(padded, PROCESSED_MAX_DIM, PROCESSED_MAX_DIM)
- processed_size = processed.size
- scale_x = processed_size[0] / padded_size[0] if padded_size[0] else 1.0
- scale_y = processed_size[1] / padded_size[1] if padded_size[1] else 1.0
- processed_rgb = processed.convert("RGB") if processed.mode != "RGB" else processed
- transform = PageDetectionTransform(
- original_size=original_size,
- border=(border_w, border_h),
- padded_size=padded_size,
- processed_size=processed_size,
- scale_x=scale_x,
- scale_y=scale_y,
- )
- return processed_rgb, transform
-
-
-def convert_boxes_to_original_polygons(
- boxes: Iterable[PageBox],
- transform: PageDetectionTransform,
-) -> list[PageObject]:
- """Map normalized Gemini bounding boxes back to polygons on the original image."""
- processed_w, processed_h = transform.processed_size
- original_w, original_h = transform.original_size
- border_w, border_h = transform.border
- scale_x = transform.scale_x or 1.0
- scale_y = transform.scale_y or 1.0
-
- polygons: list[PageObject] = []
- for box in boxes:
- left_proc, top_proc, right_proc, bottom_proc = box.denormalize(processed_w, processed_h)
- left_padded = left_proc / scale_x
- right_padded = right_proc / scale_x
- top_padded = top_proc / scale_y
- bottom_padded = bottom_proc / scale_y
-
- left_orig = max(0.0, min(original_w, left_padded - border_w))
- right_orig = max(0.0, min(original_w, right_padded - border_w))
- top_orig = max(0.0, min(original_h, top_padded - border_h))
- bottom_orig = max(0.0, min(original_h, bottom_padded - border_h))
-
- polygons.append(
- PageObject.from_bounds(
- left_orig,
- top_orig,
- right_orig,
- bottom_orig,
- object_id=f"box-{box.page_index}-{len(polygons)}",
- )
- )
-
- return polygons
-
-
-def draw_boxes(image: Image.Image, boxes: Iterable[PageBox]) -> Image.Image:
- """Overlay denormalized boxes onto a copy of the image."""
- annotated = image.copy()
- draw = ImageDraw.Draw(annotated)
- width, height = annotated.size
-
- for box in boxes:
- left, top, right, bottom = box.denormalize(width, height)
- draw.rectangle(
- [left, top, right, bottom],
- outline=GUIDELINE_COLOR,
- width=PAGE_DETECTION_BOX_WIDTH,
- )
- return annotated
-
-
-def extract_crops(source: Image.Image, polygons: Iterable[PageObject]) -> list[Image.Image]:
- """Create page crops from the provided polygons."""
- return [extract_polygon_region(source, polygon) for polygon in polygons]
-
-
-__all__ = [
- "prepare_page_image",
- "convert_boxes_to_original_polygons",
- "draw_boxes",
- "extract_crops",
-]
diff --git a/page_boundary/_models.py b/page_boundary/_models.py
deleted file mode 100644
index 6b78bde..0000000
--- a/page_boundary/_models.py
+++ /dev/null
@@ -1,101 +0,0 @@
-"""Dataclasses describing Gemini page detection structures."""
-
-from __future__ import annotations
-
-from dataclasses import dataclass
-from typing import Any
-
-from PIL import Image
-
-from churro.page import PageObject
-
-from ._constants import NORMALIZED_MAX_COORD, NORMALIZED_MIN_COORD
-
-
-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
-class PageDetectionTransform:
- """Stores geometry needed to map detection outputs back to the original image."""
-
- 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
-
-
-@dataclass
-class PageBox:
- """Normalized Gemini bounding box."""
-
- 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 Gemini response.")
- required_keys = {"left", "top", "right", "bottom"}
- if not required_keys.issubset(payload):
- missing = required_keys - set(payload)
- raise ValueError(
- f"Gemini response must include keys {sorted(required_keys)},"
- f" missing {sorted(missing)}.",
- )
-
- ymin = _clamp_normalized(float(payload["top"]))
- xmin = _clamp_normalized(float(payload["left"]))
- ymax = _clamp_normalized(float(payload["bottom"]))
- xmax = _clamp_normalized(float(payload["right"]))
- return cls(
- page_index=int(payload["page_index"]),
- ymin=ymin,
- xmin=xmin,
- ymax=ymax,
- xmax=xmax,
- )
-
- def denormalize(self, width: int, height: int) -> tuple[int, int, int, int]:
- """Convert 0-1000 normalized box to pixel coordinates."""
- 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
-
- def to_json_dict(self) -> dict[str, Any]:
- return {
- "page_index": self.page_index,
- "left": self.xmin,
- "top": self.ymin,
- "right": self.xmax,
- "bottom": self.ymax,
- }
-
-
-@dataclass
-class PageDetectionResult:
- """Outputs produced by the Gemini page detection pipeline."""
-
- crops: list[Image.Image]
- boxes: list[PageBox]
- polygons_aligned: list[PageObject]
- polygons_original: list[PageObject]
- annotated_image: Image.Image
- transform: PageDetectionTransform
-
-
-__all__ = [
- "PageDetectionTransform",
- "PageBox",
- "PageDetectionResult",
-]
diff --git a/page_boundary/_pipeline.py b/page_boundary/_pipeline.py
deleted file mode 100644
index 02aee68..0000000
--- a/page_boundary/_pipeline.py
+++ /dev/null
@@ -1,203 +0,0 @@
-"""Core Gemini page boundary detection pipeline primitives."""
-
-from __future__ import annotations
-
-from collections.abc import Iterable, Sequence
-from pathlib import Path
-
-from PIL import Image
-
-from churro.utils.llm import run_llm_async
-from churro.utils.log_utils import logger
-
-from ._constants import (
- DAMPENING_CONSTANT,
- MAX_PAGE_REVIEW_ROUNDS,
- PAGE_DETECTION_PROMPT,
- PAGE_REVIEW_PROMPT_TEMPLATE,
-)
-from ._image_processing import draw_boxes
-from ._models import PageBox
-from ._serialization import boxes_equal, parse_pages_json
-
-
-async def detect_page_boxes(image: Image.Image, model_key: str) -> list[PageBox]:
- """Call Gemini to predict normalized page bounding boxes."""
- response_text = await run_llm_async(
- model=model_key,
- system_prompt_text=PAGE_DETECTION_PROMPT,
- user_message_text=None,
- user_message_image=image,
- image_detail="high",
- output_json=True,
- )
- logger.info(f"Initial Gemini response: {response_text}")
- return parse_pages_json(response_text)
-
-
-async def review_page_boxes(
- annotated_image: Image.Image,
- history_summary: str,
- history_steps: int,
- model_key: str,
-) -> list[PageBox]:
- """Review and optionally refine bounding boxes using Gemini with visual feedback."""
- review_prompt = PAGE_REVIEW_PROMPT_TEMPLATE
- if history_summary:
- history_sections = [
- "Here are the coordinates for the currently drawn bounding boxes:",
- history_summary,
- ]
- review_prompt += "\n\n" + "\n".join(history_sections)
- response_text = await run_llm_async(
- model=model_key,
- system_prompt_text=review_prompt,
- user_message_text=None,
- user_message_image=annotated_image,
- image_detail="high",
- output_json=True,
- )
- logger.info(
- f"Review Gemini response (history rounds={history_steps}): {response_text}",
- )
- return parse_pages_json(response_text)
-
-
-async def run_detection_pipeline(
- image: Image.Image,
- model_key: str,
- max_review_rounds: int = MAX_PAGE_REVIEW_ROUNDS,
-) -> list[PageBox]:
- """Perform initial Gemini detection and iterative review refinement."""
- initial_boxes = await detect_page_boxes(image, model_key=model_key)
- history_boxes: list[list[PageBox]] = [initial_boxes]
- final_boxes = initial_boxes
-
- for round_idx in range(max(0, max_review_rounds)):
- history_summary = _format_boxes_for_prompt(history_boxes[-1])
- dampening_factor = DAMPENING_CONSTANT ** len(history_boxes)
- annotated_preview = draw_boxes(image, history_boxes[-1])
- reviewed_boxes: list[PageBox] | None = None
- try:
- reviewed_boxes = await review_page_boxes(
- annotated_preview,
- history_summary,
- history_steps=len(history_boxes),
- model_key=model_key,
- )
- except Exception as exc: # pylint: disable=broad-except
- logger.info(
- f"Review round {round_idx + 1} failed, stopping reviews: {exc}",
- )
- break
-
- if reviewed_boxes is None:
- break
-
- if not reviewed_boxes:
- break
-
- dampened_boxes = _apply_dampening(history_boxes[-1], reviewed_boxes, dampening_factor)
-
- if boxes_equal(dampened_boxes, history_boxes[-1]):
- final_boxes = dampened_boxes
- break
-
- history_boxes.append(dampened_boxes)
- final_boxes = dampened_boxes
-
- _log_box_history(history_boxes)
-
- return final_boxes
-
-
-def save_page_crops(crops: Iterable[Image.Image], output_path: Path) -> list[Path]:
- """Persist each page crop to disk next to the annotated image."""
- saved_paths: list[Path] = []
- for idx, crop in enumerate(crops, start=1):
- crop_path = output_path.with_name(f"{output_path.stem}_page{idx}.png")
- crop.save(crop_path)
- saved_paths.append(crop_path)
- return saved_paths
-
-
-__all__ = [
- "detect_page_boxes",
- "review_page_boxes",
- "run_detection_pipeline",
- "save_page_crops",
-]
-
-
-def _build_coordinate_history(
- history_boxes: Sequence[Sequence[PageBox]],
-) -> dict[int, dict[str, list[int]]]:
- per_page_history: dict[int, dict[str, list[int]]] = {}
- for boxes in history_boxes:
- for box in boxes:
- page = per_page_history.setdefault(
- box.page_index,
- {"left": [], "top": [], "right": [], "bottom": []},
- )
- page["left"].append(box.xmin)
- page["top"].append(box.ymin)
- page["right"].append(box.xmax)
- page["bottom"].append(box.ymax)
- return per_page_history
-
-
-def _format_boxes_for_prompt(boxes: Sequence[PageBox]) -> str:
- if not boxes:
- return ""
- label_width = max(len(key) for key in ("left", "top", "right", "bottom"))
- lines: list[str] = []
- for box in sorted(boxes, key=lambda item: item.page_index):
- lines.append(f"Page {box.page_index}:")
- lines.append(f"{'left'.ljust(label_width)}: {box.xmin}")
- lines.append(f"{'top'.ljust(label_width)}: {box.ymin}")
- lines.append(f"{'right'.ljust(label_width)}: {box.xmax}")
- lines.append(f"{'bottom'.ljust(label_width)}: {box.ymax}")
- return "\n".join(lines)
-
-
-def _log_box_history(history_boxes: Sequence[Sequence[PageBox]]) -> None:
- per_page_history = _build_coordinate_history(history_boxes)
- 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(f"Page {page_index} coordinate history:")
- history = per_page_history[page_index]
- for key in ("left", "top", "right", "bottom"):
- formatted = " -> ".join(str(value) for value in history[key])
- logger.info(f"{key.ljust(label_width)}: {formatted}")
-
-
-def _apply_dampening(
- previous_boxes: Sequence[PageBox],
- updated_boxes: Sequence[PageBox],
- factor: float,
-) -> list[PageBox]:
- prev_by_page = {box.page_index: box for box in previous_boxes}
- dampened: list[PageBox] = []
- for box in updated_boxes:
- previous = prev_by_page.get(box.page_index)
- if previous is None:
- dampened.append(box)
- continue
- dampened.append(
- PageBox(
- page_index=box.page_index,
- ymin=_blend_coordinate(previous.ymin, box.ymin, factor),
- xmin=_blend_coordinate(previous.xmin, box.xmin, factor),
- ymax=_blend_coordinate(previous.ymax, box.ymax, factor),
- xmax=_blend_coordinate(previous.xmax, box.xmax, factor),
- )
- )
- return dampened
-
-
-def _blend_coordinate(previous: int, current: int, factor: float) -> int:
- delta = current - previous
- adjusted = previous + delta * factor
- return max(0, min(1000, int(round(adjusted))))
diff --git a/page_boundary/_serialization.py b/page_boundary/_serialization.py
deleted file mode 100644
index 9079b36..0000000
--- a/page_boundary/_serialization.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""Serialization helpers for Gemini page boundary payloads."""
-
-from __future__ import annotations
-
-from collections.abc import Iterable
-import json
-
-from ._models import PageBox
-
-
-def strip_code_fence(raw: str) -> str:
- """Remove a leading/trailing triple-backtick fence if present."""
- 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_pages_json(raw: str) -> list[PageBox]:
- cleaned = strip_code_fence(raw)
- try:
- parsed = json.loads(cleaned)
- except json.JSONDecodeError as exc:
- raise ValueError(f"Failed to decode Gemini response as JSON: {exc}") from exc
-
- pages_data = parsed.get("pages", [])
- if not isinstance(pages_data, list):
- raise ValueError("Gemini response JSON must include a 'pages' list.")
- page_boxes = [PageBox.from_json(item) for item in pages_data]
- return sorted(page_boxes, key=lambda box: box.page_index)
-
-
-def boxes_to_json_payload(boxes: Iterable[PageBox]) -> str:
- payload = {"pages": [box.to_json_dict() for box in boxes]}
- return json.dumps(payload, ensure_ascii=False)
-
-
-def boxes_equal(a: Iterable[PageBox], b: Iterable[PageBox]) -> bool:
- list_a = list(a)
- list_b = list(b)
- if len(list_a) != len(list_b):
- return False
- for box_a, box_b in zip(list_a, list_b, strict=False):
- if box_a.page_index != box_b.page_index:
- return False
- coords_a = (box_a.ymin, box_a.xmin, box_a.ymax, box_a.xmax)
- coords_b = (box_b.ymin, box_b.xmin, box_b.ymax, box_b.xmax)
- if coords_a != coords_b:
- return False
- return True
-
-
-__all__ = [
- "parse_pages_json",
- "boxes_to_json_payload",
- "boxes_equal",
-]
diff --git a/page_boundary/cli.py b/page_boundary/cli.py
deleted file mode 100644
index fa814a2..0000000
--- a/page_boundary/cli.py
+++ /dev/null
@@ -1,103 +0,0 @@
-"""Command-line interface for Gemini page boundary detection."""
-
-from __future__ import annotations
-
-import argparse
-import asyncio
-from pathlib import Path
-
-from PIL import Image
-
-from churro.utils.llm import log_total_llm_cost
-from churro.utils.log_utils import logger
-
-from ._constants import DEFAULT_MODEL_KEY, MAX_PAGE_REVIEW_ROUNDS
-from ._pipeline import save_page_crops
-from ._serialization import boxes_to_json_payload
-from .detector import run_page_detection
-
-
-def parse_args() -> argparse.Namespace:
- """Parse CLI arguments for running the Gemini page boundary detector."""
- parser = argparse.ArgumentParser(
- description="Detect document page boundaries using Gemini.",
- )
- parser.add_argument(
- "image",
- type=Path,
- help="Path to the input image containing one or two document pages.",
- )
- parser.add_argument(
- "-o",
- "--output",
- type=Path,
- help="Path for the annotated image. Defaults to _boxed.png.",
- )
- parser.add_argument(
- "--model",
- type=str,
- default=DEFAULT_MODEL_KEY,
- help="Logical model key defined in utils.llm.models.MODEL_MAP.",
- )
- parser.add_argument(
- "--max-review-rounds",
- type=int,
- default=MAX_PAGE_REVIEW_ROUNDS,
- help="Maximum number of Gemini review passes to perform.",
- )
- return parser.parse_args()
-
-
-async def _async_main(
- image_path: Path,
- output_path: Path,
- model_key: str,
- max_review_rounds: int,
-) -> None:
- """Run the detection pipeline and persist annotated outputs asynchronously."""
- with Image.open(image_path) as img:
- original_rgb = img.convert("RGB")
- detection_result = await run_page_detection(
- original_rgb,
- model_key=model_key,
- max_review_rounds=max_review_rounds,
- )
- detection_result.annotated_image.save(output_path)
-
- crop_paths = save_page_crops(detection_result.crops, output_path)
- for idx, polygon in enumerate(detection_result.polygons_original, start=1):
- left, top, right, bottom = polygon.bounds
- logger.info(
- f"Page {idx} original coords: left={left:.1f}, top={top:.1f},"
- f" right={right:.1f}, bottom={bottom:.1f}",
- )
- for path in crop_paths:
- logger.info(f"Saved page crop: {path}")
- logger.info(f"Final boxes JSON: {boxes_to_json_payload(detection_result.boxes)}")
- logger.info(
- f"Saved annotated image with {len(detection_result.boxes)} page box(es)"
- f" to '{output_path}'",
- )
- log_total_llm_cost()
-
-
-def main() -> None:
- """CLI entry point for Gemini page boundary detection."""
- args = parse_args()
- image_path = args.image
- if not image_path.exists():
- raise FileNotFoundError(f"Missing input image: {image_path}")
- if not image_path.is_file():
- raise ValueError(f"Input path must be a file: {image_path}")
-
- default_output = image_path.with_name(f"{image_path.stem}_boxed.png")
- output_path: Path = args.output or default_output
-
- asyncio.run(_async_main(image_path, output_path, args.model, args.max_review_rounds))
-
-
-__all__ = ["main"]
-
-
-if __name__ == "__main__":
- main()
diff --git a/page_boundary/detector.py b/page_boundary/detector.py
deleted file mode 100644
index 9375e63..0000000
--- a/page_boundary/detector.py
+++ /dev/null
@@ -1,65 +0,0 @@
-"""High-level Gemini page boundary detector interfaces."""
-
-from __future__ import annotations
-
-from PIL import Image
-
-from ._constants import DEFAULT_MODEL_KEY, MAX_PAGE_REVIEW_ROUNDS
-from ._image_processing import (
- convert_boxes_to_original_polygons,
- draw_boxes,
- extract_crops,
- prepare_page_image,
-)
-from ._models import PageDetectionResult
-from ._pipeline import run_detection_pipeline
-
-
-class GeminiPageBoundaryDetector:
- """Runs the Gemini page detection pipeline with optional review rounds."""
-
- def __init__(
- self,
- model_key: str = DEFAULT_MODEL_KEY,
- max_review_rounds: int = MAX_PAGE_REVIEW_ROUNDS,
- ) -> None:
- self.model_key = model_key
- self.max_review_rounds = max(0, max_review_rounds)
-
- async def detect(self, image: Image.Image) -> PageDetectionResult:
- processed_image, transform = prepare_page_image(image)
- final_boxes = await run_detection_pipeline(
- processed_image,
- model_key=self.model_key,
- max_review_rounds=self.max_review_rounds,
- )
- annotated = draw_boxes(processed_image, final_boxes)
- polygons_original = convert_boxes_to_original_polygons(final_boxes, transform)
- crops = extract_crops(image, polygons_original)
- return PageDetectionResult(
- crops=crops,
- boxes=final_boxes,
- polygons_aligned=polygons_original,
- polygons_original=polygons_original,
- annotated_image=annotated,
- transform=transform,
- )
-
-
-async def run_page_detection(
- image: Image.Image,
- model_key: str = DEFAULT_MODEL_KEY,
- max_review_rounds: int = MAX_PAGE_REVIEW_ROUNDS,
-) -> PageDetectionResult:
- """Run Gemini page detection and return crops plus metadata."""
- detector = GeminiPageBoundaryDetector(
- model_key=model_key,
- max_review_rounds=max_review_rounds,
- )
- return await detector.detect(image)
-
-
-__all__ = [
- "GeminiPageBoundaryDetector",
- "run_page_detection",
-]
diff --git a/pixi.lock b/pixi.lock
index aec5c4a..29e74a9 100644
--- a/pixi.lock
+++ b/pixi.lock
@@ -1,6 +1,6 @@
version: 6
environments:
- default:
+ cuda:
channels:
- url: https://conda.anaconda.org/conda-forge/
- url: https://conda.anaconda.org/main/
@@ -8,209 +8,466 @@ environments:
- url: https://conda.anaconda.org/msys2/
indexes:
- https://pypi.org/simple
+ options:
+ pypi-prerelease-mode: if-necessary-or-explicit
packages:
linux-64:
- - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2
- - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2
- - conda: https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-h39aace5_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-hb03c661_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-cudart-12.9.79-h5888daf_0.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/cuda-cudart_linux-64-12.9.79-h3f2d84a_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-libraries-12.9.1-ha770c72_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-nvrtc-12.9.86-hecca717_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-opencl-12.9.19-h5888daf_0.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/cuda-version-12.9-h4f385c5_3.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/cudnn-9.13.1.26-hbcb9cd8_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-ha97dd6f_2.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.76-h0b2e76d_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/cudnn-9.10.2.21-hbcb9cd8_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_101.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libattr-2.5.2-hb03c661_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.77-h3ff7636_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcublas-12.9.1.4-h676940d_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-9.13.1.26-hf7e9902_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-dev-9.13.1.26-h58dd1b1_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-9.10.2.21-hf7e9902_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-dev-9.10.2.21-h58dd1b1_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcufft-11.4.1.4-hecca717_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcufile-1.14.1.1-hbc026e6_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcurand-10.3.10.19-h676940d_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcusolver-11.7.5.82-h676940d_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcusparse-12.5.10.65-hecca717_2.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-lib-1.11.1-hb9d3cd8_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgpg-error-1.55-h3f2d84a_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnl-3.11.0-hb9d3cd8_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnpp-12.4.1.87-h676940d_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnvfatbin-12.9.82-hecca717_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnvjitlink-12.9.86-hecca717_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnvjpeg-12.4.0.76-hecca717_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.9-h996ca69_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.9-h085a93f_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-hd0affe5_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-hd0affe5_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ocl-icd-2.3.3-hb9d3cd8_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-h5888daf_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.4-h26f9b46_0.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.2-pyh8b19718_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.12-hfe2f287_0_cpython.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/rdma-core-60.0-hecca717_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda
- - pypi: https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/rdma-core-61.0-h192683f_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda
+ - pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d2/59/d19bc3257dd985d55337d7f0414c019414b97e16cd3690ebf9941a847543/av-17.0.0-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/59/911a1a597264f1fb7ac176995a0f0b6062e37f8c1b6e0f23071a76838507/cuda_pathfinder-1.4.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/34/c6/b8298394a8926f8af21c059f4ec794b61f9cf64ade561184027b94110639/datasets-4.8.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3e/af/3e990d8d4002bbc9342adb4facd59506e653da93b2417de0fa6027cb86b1/evaluate-0.4.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/3a/590d58dee65a238f7f3d5c37f8f9f9021ecaf27fe379a393b4259324b56e/litellm-1.82.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/9d/b7/a2bae25aae3568fe9f17040b31f9c190b4c5d86856d8869d0a30364a2567/pydata_sphinx_theme-0.17.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ff/49/a640b288a48dab1752281dd9b72c0679fccea107874e80a65a606b00efa9/pypdfium2-5.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c4/43/80f67e0336cb2fc725f8e06f7fe35c1d0fe946f4d2b8b2175e797e07349e/qwen_vl_utils-0.0.14-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl
+ - 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/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-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
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/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
+ - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl
+ - pypi: ./
+ default:
+ channels:
+ - url: https://conda.anaconda.org/conda-forge/
+ - url: https://conda.anaconda.org/main/
+ - url: https://conda.anaconda.org/r/
+ - url: https://conda.anaconda.org/msys2/
+ indexes:
+ - https://pypi.org/simple
+ options:
+ pypi-prerelease-mode: if-necessary-or-explicit
+ packages:
+ linux-64:
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_101.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda
+ - pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/14/51/d0c1701a79fcb0109cff5304da16226581569b89a282d8e7f1549a7e3ec0/aiohttp-3.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/76/74/5d90ad14d55fbe3f9c474fdcb6e34b4bed99e3be8efac98734a5ddce88c1/anthropic-0.49.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d2/59/d19bc3257dd985d55337d7f0414c019414b97e16cd3690ebf9941a847543/av-17.0.0-cp311-abi3-manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/b1/3c/b90d5afc2e47c4a45f4bba00f9c3193b0417fad5ad3bb07869f9d12832aa/azure_core-1.36.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/91/9e/0bbbd09b116fd8ee2d3617e28e6598551d2f0f24d3a2ce99cc87ec85aeb0/datasets-4.2.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/59/911a1a597264f1fb7ac176995a0f0b6062e37f8c1b6e0f23071a76838507/cuda_pathfinder-1.4.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/34/c6/b8298394a8926f8af21c059f4ec794b61f9cf64ade561184027b94110639/datasets-4.8.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a9/b6/f85666707b9f9ff94ada851cbaa6f1c91a0f5802aa5e498772251e1e7772/elementpath-5.0.4-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/3e/af/3e990d8d4002bbc9342adb4facd59506e653da93b2417de0fa6027cb86b1/evaluate-0.4.6-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/6a/2b/1b89e90a8635e5587ccdbbeb169c590672ce7637880f2c047482a0359950/fastuuid-0.13.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/ee/1b/00a78aa2e8fbd63f9af08c9c19e6deb3d5d66b4dda677a0f61654680ee89/flatbuffers-25.9.23-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/77/ad/f73cf9fe9bd95918502b270e3ddb8764e4c900b3bbd7782b90c56fac14bb/google_api_core-2.26.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f4/38/d25ae1565103a545cf18207a5dec09a6d39ad88e5b0399a2430e9edb0550/google_api_python_client-2.184.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/be/8a/fe34d2f3f9470a27b01c9e76226965863f153d5fbe276f83608562e49c04/google_auth_httplib2-0.2.0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/bd/f6/806b39f86f912133a3071ffa9ff99801a12868216069e26c83a48943116b/google_cloud_aiplatform-1.121.0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/39/3c/c8cada9ec282b29232ed9aed5a0b5cca6cf5367cb2ffa8ad0d2583d743f1/google_cloud_bigquery-3.38.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/40/86/bda7241a8da2d28a754aad2ba0f6776e35b67e37c36ae0c45d49370f1014/google_cloud_core-2.4.3-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/24/92/e3c4850d5d1dd83da7538f1ee91baac9d4bc849e96899bc23ddbe20f15f4/google_cloud_documentai-3.6.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/b1/ea/a92631c358da377af34d3a9682c97af83185c2d66363d5939ab4a1169a7f/google_cloud_resource_manager-1.14.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/d5/94/6db383d8ee1adf45dc6c73477152b82731fa4c4a46d9c1932cc8757e0fd4/google_cloud_storage-2.19.0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/3b/a5/7279055cf004561894ed3a7bfdf5bf90a53f28fadd01af7cd166e88ddf16/google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/11/8f/922116dabe3d0312f08903d324db6ac9d406832cf57707550bc61151d91b/google_genai-1.45.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/82/35/b8d3baf8c46695858cb9d8835a53baa1eeb9906ddaf2f728a5f5b640fd1e/google_resumable_media-2.7.2-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/1e/9c/eda9fe57f2b84343d44c1b66cf3831c973ba29b078b16a27d4587a1fdd47/grpcio-1.75.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/d8/ad/6f414bb0b36eee20d93af6907256f208ffcda992ae6d3d7b6a778afe31e6/grpcio_status-1.75.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/15/07/86397573efefff941e100367bbda0b21496ffcdb34db7ab51912994c32a2/hf_xet-1.1.10-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/8c/a2/0d269db0f6163be503775dc8b6a6fa15820cc9fdc866f6ba608d86b721f2/httplib2-0.31.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/31/a0/651f93d154cb72323358bf2bbae3e642bdb5d2f1bfc874d096f7cb159fa0/huggingface_hub-0.35.3-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/cb/bd/b394387b598ed84d8d0fa90611a90bee0adc2021820ad5729f7ced74a8e2/imageio-2.37.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/9a/5f/0dc34563d8164d31d07bc09d141d3da08157a68dcd1f9b886fa4e917805b/jiter-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/1e/e8/685f47e0d754320684db4425a0967f7d3fa70126bffd76110b7009a0090f/joblib-1.5.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/fc/fb/38a48efe3e05a8e9a9765b991740282e0358a83fb896ec00d70bf1448791/litellm-1.78.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/3a/590d58dee65a238f7f3d5c37f8f9f9021ecaf27fe379a393b4259324b56e/litellm-1.82.3-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/fe/76/4ce12563aea5a76016f8643eff30ab731e6656c845e9e4d090ef10c7b925/mistralai-1.9.11-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/0a/7d/a988f258104dcd2ccf1ed40fdc97e26c4ac351eeaf81d76e266c52d84e2f/multiprocess-0.70.16-py312-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/6c/d9/b7140a4f1615195938c7e358c0804bb84271f0d6886b5cbf105c6cb58aae/onnxruntime_gpu-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/9c/5b/4be258ff072ed8ee15f6bfd8d5a1a4618aa4704b127c0c5959212ad177d6/openai-2.3.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/6c/98/468cb649f208a6f1279448e6e5247b37ae79cf5e4041186f1e2ef3d16345/pydantic-2.12.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/9d/b7/a2bae25aae3568fe9f17040b31f9c190b4c5d86856d8869d0a30364a2567/pydata_sphinx_theme-0.17.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/5b/5a/1292a0df4ff71fbc00dfa8c08759d17c97e1e8ea9277eb5bc5f079ca188d/pymupdf-1.26.5-cp39-abi3-manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ff/49/a640b288a48dab1752281dd9b72c0679fccea107874e80a65a606b00efa9/pypdfium2-5.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/20/14/7399c18c460e72d1b754e80dafc9f65cb42a46cc8f29cd57d11c0c4acc94/rapidfuzz-3.14.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c4/43/80f67e0336cb2fc725f8e06f7fe35c1d0fe946f4d2b8b2175e797e07349e/qwen_vl_utils-0.0.14-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/35/9e/a91b50332a9750519320ed30ec378b74c996f6befe282cfa6bb6cea7e9fd/regex-2025.9.18-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e2/c5/9136736c37022a6ad27fea38f3111eb8f02fe75d067f9a985cc358653102/rouge_score-0.1.2.tar.gz
- - pypi: https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/53/11/a0160990b82999b45874dc60c0c183d3a3a969a563fffc476d5a9995c407/scipy-1.16.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl
+ - 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/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-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
- pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/6e/ff/e2f8dae90fb642b4b6f24464a2f96a3dc3b69151c51f7db24433be0f3f56/tifffile-2025.10.4-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/d0/c6/dc3a0db5a6766416c32c034286d7c2d406da1f498e4de04ab1b8959edd00/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/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
- - pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/dc/1a/81ac398d35be848f3655893b6260f227b29ca6acf5c2674dc5ed9af63027/xmlschema-4.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl
- pypi: ./
minimal:
@@ -221,48 +478,102 @@ environments:
- url: https://conda.anaconda.org/msys2/
indexes:
- https://pypi.org/simple
+ options:
+ pypi-prerelease-mode: if-necessary-or-explicit
packages:
linux-64:
- - conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2
- - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2
- - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1aa0949_4.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_101.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.4-h26f9b46_0.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.2-pyh8b19718_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.12-hd63d673_1_cpython.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
- - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda
- - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda
- - pypi: https://files.pythonhosted.org/packages/77/85/85951bc0f9843e2c10baaa1b6657227056095de08f4d1eea7d8b423a6832/accelerate-1.11.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda
+ - pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d2/59/d19bc3257dd985d55337d7f0414c019414b97e16cd3690ebf9941a847543/av-17.0.0-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c0/59/911a1a597264f1fb7ac176995a0f0b6062e37f8c1b6e0f23071a76838507/cuda_pathfinder-1.4.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/34/c6/b8298394a8926f8af21c059f4ec794b61f9cf64ade561184027b94110639/datasets-4.8.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3e/af/3e990d8d4002bbc9342adb4facd59506e653da93b2417de0fa6027cb86b1/evaluate-0.4.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
@@ -276,55 +587,95 @@ environments:
- pypi: https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/3b/6c/99acb2f9eb85c29fc6f3a7ac4dccfd992e22666dd08a642b303311326a97/nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/30/28/5e27f4d5a0e347f8e3cc16cd7d35533dbce086c95807f1f0e9cd77e26c10/psutil-7.1.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9d/b7/a2bae25aae3568fe9f17040b31f9c190b4c5d86856d8869d0a30364a2567/pydata_sphinx_theme-0.17.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/99/14/9a39b7c9e007968411bc3c843cc14cf15437510c0a9991f080cab654fd16/regex-2025.10.23-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/c4/43/80f67e0336cb2fc725f8e06f7fe35c1d0fe946f4d2b8b2175e797e07349e/qwen_vl_utils-0.0.14-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/fe/5d/5a514d7b88e310c8b146e2404e0dc161282e78634d9358975fd56dfd14be/safetensors-0.6.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl
+ - 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/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-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
+ - pypi: https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/d0/c6/dc3a0db5a6766416c32c034286d7c2d406da1f498e4de04ab1b8959edd00/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/a5/4b/f4bb2e6c25d0272f798cd6d7a04ed315da76cec68c602d87040c7847287f/torch-2.9.0-cp312-cp312-manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/7e/e6/7324ead6793075a8c75c56abeed1236d1750de16a5613cfe2ddad164a92a/torchvision-0.24.0-cp312-cp312-manylinux_2_28_x86_64.whl
- - pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/71/d3/c16c3b3cf7655a67db1144da94b021c200ac1303f82428f2beef6c2e72bb/transformers-4.57.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/f5/3a/e991574f3102147b642e49637e0281e9bb7c4ba254edb2bab78247c85e01/triton-3.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.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/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/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl
+ - pypi: https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ - pypi: ./
packages:
-- conda: https://conda.anaconda.org/conda-forge/linux-64/_libgcc_mutex-0.1-conda_forge.tar.bz2
- sha256: fe51de6107f9edc7aa4f786a70f4a883943bc9d39b3bb7307c04c41410990726
- md5: d7c89558ba9fa0495403155b64376d81
- license: None
- purls: []
- size: 2562
- timestamp: 1578324546067
-- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-2_gnu.tar.bz2
- build_number: 16
- sha256: fbe2c5e56a653bebb982eda4876a9178aedfc2b545f25d0ce9c4c0b508253d22
- md5: 73aaf86a425cc6e73fcf236a5a46396d
+- conda: https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda
+ build_number: 20
+ sha256: 1dd3fffd892081df9726d7eb7e0dea6198962ba775bd88842135a4ddb4deb3c9
+ md5: a9f577daf3de00bca7c3c76c0ecbd1de
depends:
- - _libgcc_mutex 0.1 conda_forge
+ - __glibc >=2.17,<3.0.a0
- libgomp >=7.5.0
constrains:
- - openmp_impl 9999
+ - openmp_impl <0.0a0
license: BSD-3-Clause
license_family: BSD
purls: []
- size: 23621
- timestamp: 1650670423406
-- pypi: https://files.pythonhosted.org/packages/8f/aa/ba0014cc4659328dc818a28827be78e6d97312ab0cb98105a770924dc11e/absl_py-2.3.1-py3-none-any.whl
- name: absl-py
- version: 2.3.1
- sha256: eeecf07f0c2a93ace0772c92e596ace6d3d3996c042b2128459aaae2a76de11d
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/77/85/85951bc0f9843e2c10baaa1b6657227056095de08f4d1eea7d8b423a6832/accelerate-1.11.0-py3-none-any.whl
+ size: 28948
+ timestamp: 1770939786096
+- pypi: https://files.pythonhosted.org/packages/7e/46/02ac5e262d4af18054b3e922b2baedbb2a03289ee792162de60a865defc5/accelerate-1.13.0-py3-none-any.whl
name: accelerate
- version: 1.11.0
- sha256: a628fa6beb069b8e549460fc449135d5bd8d73e7a11fd09f0bc9fc4ace7f06f1
+ version: 1.13.0
+ sha256: cf1a3efb96c18f7b152eb0fa7490f3710b19c3f395699358f08decca2b8b62e0
requires_dist:
- numpy>=1.17
- packaging>=20.0
@@ -396,20 +747,30 @@ packages:
- rich ; extra == 'dev'
- sagemaker ; extra == 'sagemaker'
requires_python: '>=3.10.0'
-- pypi: https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl
- name: aiofiles
- version: 24.1.0
- sha256: b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5
- requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/8d/3f/95338030883d8c8b91223b4e21744b04d11b161a3ef117295d8241f50ab4/accessible_pygments-0.0.5-py3-none-any.whl
+ name: accessible-pygments
+ version: 0.0.5
+ sha256: 88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7
+ requires_dist:
+ - pygments>=1.5
+ - pillow ; extra == 'dev'
+ - pkginfo>=1.10 ; extra == 'dev'
+ - playwright ; extra == 'dev'
+ - pre-commit ; extra == 'dev'
+ - setuptools ; extra == 'dev'
+ - twine>=5.0 ; extra == 'dev'
+ - hypothesis ; extra == 'tests'
+ - pytest ; extra == 'tests'
+ requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl
name: aiohappyeyeballs
version: 2.6.1
sha256: f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/14/51/d0c1701a79fcb0109cff5304da16226581569b89a282d8e7f1549a7e3ec0/aiohttp-3.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/86/f6/a62cbbf13f0ac80a70f71b1672feba90fdb21fd7abd8dbf25c0105fb6fa3/aiohttp-3.13.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: aiohttp
- version: 3.13.0
- sha256: 2e66c57416352f36bf98f6641ddadd47c93740a22af7150d3e9a1ef6e983f9a8
+ version: 3.13.3
+ sha256: 9ae8dd55c8e6c4257eae3a20fd2c8f41edaea5992ed67156642493b8daf3cecc
requires_dist:
- aiohappyeyeballs>=2.5.0
- aiosignal>=1.4.0
@@ -420,9 +781,9 @@ packages:
- propcache>=0.2.0
- yarl>=1.17.0,<2.0
- aiodns>=3.3.0 ; extra == 'speedups'
- - brotli ; platform_python_implementation == 'CPython' and extra == 'speedups'
- - brotlicffi ; platform_python_implementation != 'CPython' and extra == 'speedups'
- - zstandard ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'speedups'
+ - brotli>=1.2 ; platform_python_implementation == 'CPython' and extra == 'speedups'
+ - brotlicffi>=1.2 ; platform_python_implementation != 'CPython' and extra == 'speedups'
+ - backports-zstd ; python_full_version < '3.14' and platform_python_implementation == 'CPython' and extra == 'speedups'
requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl
name: aiosignal
@@ -432,6 +793,16 @@ packages:
- frozenlist>=1.1.0
- typing-extensions>=4.2 ; python_full_version < '3.13'
requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl
+ name: alabaster
+ version: 1.0.0
+ sha256: fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl
+ name: annotated-doc
+ version: 0.0.4
+ sha256: 571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320
+ requires_python: '>=3.8'
- pypi: https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl
name: annotated-types
version: 0.7.0
@@ -439,49 +810,39 @@ packages:
requires_dist:
- typing-extensions>=4.0.0 ; python_full_version < '3.9'
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/76/74/5d90ad14d55fbe3f9c474fdcb6e34b4bed99e3be8efac98734a5ddce88c1/anthropic-0.49.0-py3-none-any.whl
- name: anthropic
- version: 0.49.0
- sha256: bbc17ad4e7094988d2fa86b87753ded8dce12498f4b85fe5810f208f454a8375
- requires_dist:
- - anyio>=3.5.0,<5
- - distro>=1.7.0,<2
- - httpx>=0.23.0,<1
- - jiter>=0.4.0,<1
- - pydantic>=1.9.0,<3
- - sniffio
- - typing-extensions>=4.10,<5
- - boto3>=1.28.57 ; extra == 'bedrock'
- - botocore>=1.31.57 ; extra == 'bedrock'
- - google-auth>=2,<3 ; extra == 'vertex'
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl
name: anyio
- version: 4.11.0
- sha256: 0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc
+ version: 4.12.1
+ sha256: d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c
requires_dist:
- exceptiongroup>=1.0.2 ; python_full_version < '3.11'
- idna>=2.8
- - sniffio>=1.1
- typing-extensions>=4.5 ; python_full_version < '3.13'
- - trio>=0.31.0 ; extra == 'trio'
+ - trio>=0.32.0 ; python_full_version >= '3.10' and extra == 'trio'
+ - trio>=0.31.0 ; python_full_version < '3.10' and extra == 'trio'
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-h39aace5_0.conda
- sha256: a9c114cbfeda42a226e2db1809a538929d2f118ef855372293bd188f71711c48
- md5: 791365c5f65975051e4e017b5da3abf5
+- conda: https://conda.anaconda.org/conda-forge/linux-64/attr-2.5.2-hb03c661_1.conda
+ sha256: 78c516af87437f52d883193cf167378f592ad445294c69f7c69f56059087c40d
+ md5: 9bb149f49de3f322fca007283eaa2725
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
+ - libattr 2.5.2 hb03c661_1
+ - libgcc >=14
license: GPL-2.0-or-later
license_family: GPL
purls: []
- size: 68072
- timestamp: 1756738968573
-- pypi: https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl
+ size: 31386
+ timestamp: 1773595914754
+- pypi: https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl
name: attrs
- version: 25.4.0
- sha256: adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373
+ version: 26.1.0
+ sha256: c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309
requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/d2/59/d19bc3257dd985d55337d7f0414c019414b97e16cd3690ebf9941a847543/av-17.0.0-cp311-abi3-manylinux_2_28_x86_64.whl
+ name: av
+ version: 17.0.0
+ sha256: 1060cba85f97f4a337311169d92c0b5e143452cfa5ca0e65fa499d7955e8592e
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/d9/75/c9ec040f23082f54ffb1977ff8f364c2d21c79a640a13d1c1809e7fd6b1a/azure_ai_documentintelligence-1.0.2-py3-none-any.whl
name: azure-ai-documentintelligence
version: 1.0.2
@@ -491,84 +852,197 @@ packages:
- azure-core>=1.30.0
- typing-extensions>=4.6.0
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/b1/3c/b90d5afc2e47c4a45f4bba00f9c3193b0417fad5ad3bb07869f9d12832aa/azure_core-1.36.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/7e/d6/8ebcd05b01a580f086ac9a97fb9fac65c09a4b012161cc97c21a336e880b/azure_core-1.39.0-py3-none-any.whl
name: azure-core
- version: 1.36.0
- sha256: fee9923a3a753e94a259563429f3644aaf05c486d45b1215d098115102d91d3b
+ version: 1.39.0
+ sha256: 4ac7b70fab5438c3f68770649a78daf97833caa83827f91df9c14e0e0ea7d34f
requires_dist:
- requests>=2.21.0
- typing-extensions>=4.6.0
- aiohttp>=3.0 ; extra == 'aio'
- opentelemetry-api~=1.26 ; extra == 'tracing'
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_8.conda
- sha256: c30daba32ddebbb7ded490f0e371eae90f51e72db620554089103b4a6934b0d5
- md5: 51a19bba1b8ebfb60df25cde030b7ebc
+- pypi: https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl
+ name: babel
+ version: 2.18.0
+ sha256: e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35
+ requires_dist:
+ - pytz>=2015.7 ; python_full_version < '3.9'
+ - tzdata ; sys_platform == 'win32' and extra == 'dev'
+ - backports-zoneinfo ; python_full_version < '3.9' and extra == 'dev'
+ - freezegun~=1.0 ; extra == 'dev'
+ - jinja2>=3.0 ; extra == 'dev'
+ - pytest-cov ; extra == 'dev'
+ - pytest>=6.0 ; extra == 'dev'
+ - pytz ; extra == 'dev'
+ - setuptools ; extra == 'dev'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl
+ name: beautifulsoup4
+ version: 4.14.3
+ sha256: 0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb
+ requires_dist:
+ - soupsieve>=1.6.1
+ - typing-extensions>=4.0.0
+ - cchardet ; extra == 'cchardet'
+ - chardet ; extra == 'chardet'
+ - charset-normalizer ; extra == 'charset-normalizer'
+ - html5lib ; extra == 'html5lib'
+ - lxml ; extra == 'lxml'
+ requires_python: '>=3.7.0'
+- pypi: https://files.pythonhosted.org/packages/c5/0d/84a4380f930db0010168e0aa7b7a8fed9ba1835a8fbb1472bc6d0201d529/build-1.4.0-py3-none-any.whl
+ name: build
+ version: 1.4.0
+ sha256: 6a07c1b8eb6f2b311b96fcbdbce5dab5fe637ffda0fd83c9cac622e927501596
+ requires_dist:
+ - packaging>=24.0
+ - pyproject-hooks
+ - colorama ; os_name == 'nt'
+ - importlib-metadata>=4.6 ; python_full_version < '3.10.2'
+ - tomli>=1.1.0 ; python_full_version < '3.11'
+ - uv>=0.1.18 ; extra == 'uv'
+ - virtualenv>=20.11 ; python_full_version < '3.10' and extra == 'virtualenv'
+ - virtualenv>=20.17 ; python_full_version >= '3.10' and python_full_version < '3.14' and extra == 'virtualenv'
+ - virtualenv>=20.31 ; python_full_version >= '3.14' and extra == 'virtualenv'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_9.conda
+ sha256: 0b75d45f0bba3e95dc693336fa51f40ea28c980131fec438afb7ce6118ed05f6
+ md5: d2ffd7602c02f2b316fd921d39876885
depends:
- __glibc >=2.17,<3.0.a0
- libgcc >=14
license: bzip2-1.0.6
license_family: BSD
purls: []
- size: 260341
- timestamp: 1757437258798
-- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2025.10.5-hbd8a1cb_0.conda
- sha256: 3b5ad78b8bb61b6cdc0978a6a99f8dfb2cc789a451378d054698441005ecbdb6
- md5: f9e5fbc24009179e8b0409624691758a
+ size: 260182
+ timestamp: 1771350215188
+- conda: https://conda.anaconda.org/conda-forge/noarch/ca-certificates-2026.2.25-hbd8a1cb_0.conda
+ sha256: 67cc7101b36421c5913a1687ef1b99f85b5d6868da3abbf6ec1a4181e79782fc
+ md5: 4492fd26db29495f0ba23f146cd5638d
depends:
- __unix
license: ISC
purls: []
- size: 155907
- timestamp: 1759649036195
-- pypi: https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl
- name: cachetools
- version: 6.2.1
- sha256: 09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl
+ size: 147413
+ timestamp: 1772006283803
+- pypi: https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl
name: certifi
- version: 2025.10.5
- sha256: 0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de
+ version: 2026.2.25
+ sha256: 027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+ name: cffi
+ version: 2.0.0
+ sha256: 3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba
+ requires_dist:
+ - pycparser ; implementation_name != 'PyPy'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl
name: cfgv
- version: 3.4.0
- sha256: b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+ version: 3.5.0
+ sha256: a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: charset-normalizer
- version: 3.4.4
- sha256: 11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86
+ version: 3.4.6
+ sha256: 0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5
requires_python: '>=3.7'
- pypi: ./
- name: churro
+ name: churro-ocr
version: 0.1.0
- sha256: 6c105b44011f9a92c660bf670f13d710d9869e9e98ccb01615cebd78d4adf1f9
+ sha256: 8ac842f835aac177f967c8674689b0e31b5c3708a2387cfce9518746fb12378f
+ requires_dist:
+ - loguru>=0.7.2,<1
+ - pillow>=10.4.0,<12
+ - rich>=13.9.2,<14
+ - typer>=0.12.3,<1
+ - google-auth>=2.41.1,<3 ; extra == 'llm'
+ - litellm[caching]==1.82.3 ; extra == 'llm'
+ - azure-ai-documentintelligence==1.0.2 ; extra == 'azure'
+ - qwen-vl-utils ; extra == 'hf'
+ - transformers[torch]>=4.57.0,<5 ; extra == 'hf'
+ - torchvision ; extra == 'hf'
+ - transformers>=4.57.0,<5 ; extra == 'vllm'
+ - torchvision ; extra == 'vllm'
+ - vllm>=0.18,<1 ; extra == 'vllm'
+ - mistralai>=1.6.0,<2 ; extra == 'mistral'
+ - litellm[caching]==1.82.3 ; extra == 'local'
+ - pypdfium2>=5,<6 ; extra == 'pdf'
+ - google-auth>=2.41.1,<3 ; extra == 'all'
+ - litellm[caching]==1.82.3 ; extra == 'all'
+ - azure-ai-documentintelligence==1.0.2 ; extra == 'all'
+ - qwen-vl-utils ; extra == 'all'
+ - transformers[torch]>=4.57.0,<5 ; extra == 'all'
+ - torchvision ; extra == 'all'
+ - vllm>=0.18,<1 ; extra == 'all'
+ - mistralai>=1.6.0,<2 ; extra == 'all'
+ - pypdfium2>=5,<6 ; extra == 'all'
requires_python: '>=3.12'
- editable: true
-- pypi: https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl
name: click
- version: 8.3.0
- sha256: 9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc
+ version: 8.3.1
+ sha256: 981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6
requires_dist:
- colorama ; sys_platform == 'win32'
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl
- name: coloredlogs
- version: 15.0.1
- sha256: 612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934
- requires_dist:
- - humanfriendly>=9.1
- - capturer>=2.4 ; extra == 'cron'
- requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*'
-- pypi: https://files.pythonhosted.org/packages/71/0b/d3bcbbc259fcced5fb67c5d78f6e7ee965f49760c14afd931e9e663a83b2/coverage-7.11.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl
+ name: colorama
+ version: 0.4.6
+ sha256: 4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
+ requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*'
+- pypi: https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
name: coverage
- version: 7.11.0
- sha256: 16ce17ceb5d211f320b62df002fa7016b7442ea0fd260c11cec8ce7730954893
+ version: 7.13.5
+ sha256: 03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5
requires_dist:
- tomli ; python_full_version <= '3.11' and extra == 'toml'
requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl
+ name: cryptography
+ version: 46.0.5
+ sha256: 3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed
+ requires_dist:
+ - cffi>=1.14 ; python_full_version == '3.8.*' and platform_python_implementation != 'PyPy'
+ - cffi>=2.0.0 ; python_full_version >= '3.9' and platform_python_implementation != 'PyPy'
+ - typing-extensions>=4.13.2 ; python_full_version < '3.11'
+ - bcrypt>=3.1.5 ; extra == 'ssh'
+ - nox[uv]>=2024.4.15 ; extra == 'nox'
+ - cryptography-vectors==46.0.5 ; extra == 'test'
+ - pytest>=7.4.0 ; extra == 'test'
+ - pytest-benchmark>=4.0 ; extra == 'test'
+ - pytest-cov>=2.10.1 ; extra == 'test'
+ - pytest-xdist>=3.5.0 ; extra == 'test'
+ - pretend>=0.7 ; extra == 'test'
+ - certifi>=2024 ; extra == 'test'
+ - pytest-randomly ; extra == 'test-randomorder'
+ - sphinx>=5.3.0 ; extra == 'docs'
+ - sphinx-rtd-theme>=3.0.0 ; extra == 'docs'
+ - sphinx-inline-tabs ; extra == 'docs'
+ - pyenchant>=3 ; extra == 'docstest'
+ - readme-renderer>=30.0 ; extra == 'docstest'
+ - sphinxcontrib-spelling>=7.3.1 ; extra == 'docstest'
+ - build>=1.0.0 ; extra == 'sdist'
+ - ruff>=0.11.11 ; extra == 'pep8test'
+ - mypy>=1.14 ; extra == 'pep8test'
+ - check-sdist ; extra == 'pep8test'
+ - click>=8.0.1 ; extra == 'pep8test'
+ requires_python: '>=3.8,!=3.9.0,!=3.9.1'
+- pypi: https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ name: cuda-bindings
+ version: 12.9.4
+ sha256: fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8
+ requires_dist:
+ - cuda-pathfinder~=1.1
+ - nvidia-cuda-nvcc-cu12 ; extra == 'all'
+ - nvidia-cuda-nvrtc-cu12 ; extra == 'all'
+ - nvidia-nvjitlink-cu12>=12.3 ; extra == 'all'
+ - nvidia-cufile-cu12 ; sys_platform == 'linux' and extra == 'all'
+ - cython>=3.1,<3.2 ; extra == 'test'
+ - setuptools>=77.0.0 ; extra == 'test'
+ - numpy>=1.21.1 ; extra == 'test'
+ - pytest>=6.2.4 ; extra == 'test'
+ - pytest-benchmark>=3.4.1 ; extra == 'test'
+ - pyglet>=2.1.9 ; extra == 'test'
- conda: https://conda.anaconda.org/conda-forge/linux-64/cuda-cudart-12.9.79-h5888daf_0.conda
sha256: 57d1294ecfaf9dc8cdb5fc4be3e63ebc7614538bddb5de53cfd9b1b7de43aed5
md5: cb15315d19b58bd9cd424084e58ad081
@@ -637,6 +1111,11 @@ packages:
purls: []
size: 30747
timestamp: 1746192810479
+- pypi: https://files.pythonhosted.org/packages/c0/59/911a1a597264f1fb7ac176995a0f0b6062e37f8c1b6e0f23071a76838507/cuda_pathfinder-1.4.3-py3-none-any.whl
+ name: cuda-pathfinder
+ version: 1.4.3
+ sha256: 4345d8ead1f701c4fb8a99be6bc1843a7348b6ba0ef3b031f5a2d66fb128ae4c
+ requires_python: '>=3.10'
- conda: https://conda.anaconda.org/conda-forge/noarch/cuda-version-12.9-h4f385c5_3.conda
sha256: 5f5f428031933f117ff9f7fcc650e6ea1b3fef5936cf84aa24af79167513b656
md5: b6d5d7f1c171cbd228ea06b556cfa859
@@ -647,37 +1126,37 @@ packages:
purls: []
size: 21578
timestamp: 1746134436166
-- conda: https://conda.anaconda.org/conda-forge/linux-64/cudnn-9.13.1.26-hbcb9cd8_0.conda
- sha256: e7fee0538f05969ab3b33ac93d892ab777c8ce1a34b1ffd207aa339b82e18b49
- md5: 7ebbea86a820fdc69ffa044b7c9729cb
+- conda: https://conda.anaconda.org/conda-forge/linux-64/cudnn-9.10.2.21-hbcb9cd8_0.conda
+ sha256: 5760ad9de2ecff210b018503168d26996497604608cf59f93df90f01ea4eb982
+ md5: c8168e26c0a9f50425ac05d6a5201c12
depends:
- __glibc >=2.28,<3.0.a0
- cuda-version >=12,<13.0a0
- - libcudnn-dev 9.13.1.26 h58dd1b1_0
+ - libcudnn-dev 9.10.2.21 h58dd1b1_0
- libgcc >=14
- libstdcxx >=14
constrains:
- cudnn-jit <0a
license: LicenseRef-cuDNN-Software-License-Agreement
purls: []
- size: 19353
- timestamp: 1759247527005
-- pypi: https://files.pythonhosted.org/packages/91/9e/0bbbd09b116fd8ee2d3617e28e6598551d2f0f24d3a2ce99cc87ec85aeb0/datasets-4.2.0-py3-none-any.whl
+ size: 19646
+ timestamp: 1762823905292
+- pypi: https://files.pythonhosted.org/packages/34/c6/b8298394a8926f8af21c059f4ec794b61f9cf64ade561184027b94110639/datasets-4.8.3-py3-none-any.whl
name: datasets
- version: 4.2.0
- sha256: fdc43aaf4a73b31f64f80f72f195ab413a1141ed15555d675b2fd17926f8b026
+ version: 4.8.3
+ sha256: a66cb506097bfce8461b076fb9e86ea216e00fd622387ba19909d9c1689ff8f9
requires_dist:
- filelock
- numpy>=1.17
- pyarrow>=21.0.0
- - dill>=0.3.0,<0.4.1
+ - dill>=0.3.0,<0.4.2
- pandas
- requests>=2.32.2
- httpx<1.0.0
- tqdm>=4.66.3
- xxhash
- - multiprocess<0.70.17
- - fsspec[http]>=2023.1.0,<=2025.9.0
+ - multiprocess<0.70.20
+ - fsspec[http]>=2023.1.0,<=2026.2.0
- huggingface-hub>=0.25.0,<2.0
- packaging
- pyyaml>=5.1
@@ -689,11 +1168,11 @@ packages:
- torch ; extra == 'torch'
- jax>=0.3.14 ; extra == 'jax'
- jaxlib>=0.3.14 ; extra == 'jax'
- - numba>=0.56.4 ; extra == 'dev'
+ - numba>=0.56.4 ; python_full_version < '3.14' and extra == 'dev'
- absl-py ; extra == 'dev'
- decorator ; extra == 'dev'
- joblib<1.3.0 ; extra == 'dev'
- - joblibspark ; extra == 'dev'
+ - joblibspark ; python_full_version < '3.14' and extra == 'dev'
- pytest ; extra == 'dev'
- pytest-datadir ; extra == 'dev'
- pytest-xdist ; extra == 'dev'
@@ -701,9 +1180,10 @@ packages:
- elasticsearch>=7.17.12,<8.0.0 ; extra == 'dev'
- faiss-cpu>=1.8.0.post1 ; extra == 'dev'
- h5py ; extra == 'dev'
+ - pylance ; extra == 'dev'
- jax>=0.3.14 ; sys_platform != 'win32' and extra == 'dev'
- jaxlib>=0.3.14 ; sys_platform != 'win32' and extra == 'dev'
- - lz4 ; extra == 'dev'
+ - lz4 ; python_full_version < '3.14' and extra == 'dev'
- moto[server] ; extra == 'dev'
- pyspark>=3.4 ; extra == 'dev'
- py7zr ; extra == 'dev'
@@ -711,7 +1191,7 @@ packages:
- sqlalchemy ; extra == 'dev'
- protobuf<4.0.0 ; extra == 'dev'
- tensorflow>=2.6.0 ; python_full_version < '3.10' and sys_platform != 'win32' and extra == 'dev'
- - tensorflow>=2.16.0 ; python_full_version >= '3.10' and sys_platform != 'win32' and extra == 'dev'
+ - tensorflow>=2.16.0 ; python_full_version >= '3.10' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'dev'
- tiktoken ; extra == 'dev'
- torch>=2.8.0 ; extra == 'dev'
- torchdata ; extra == 'dev'
@@ -719,16 +1199,17 @@ packages:
- zstandard ; extra == 'dev'
- polars[timezone]>=0.20.0 ; extra == 'dev'
- pillow>=9.4.0 ; extra == 'dev'
- - torchcodec>=0.7.0 ; extra == 'dev'
+ - torchcodec>=0.7.0 ; python_full_version < '3.14' and extra == 'dev'
+ - nibabel>=5.3.1 ; extra == 'dev'
- ruff>=0.3.0 ; extra == 'dev'
- transformers ; extra == 'dev'
- torch ; extra == 'dev'
- tensorflow>=2.6.0 ; extra == 'dev'
- - numba>=0.56.4 ; extra == 'tests'
+ - numba>=0.56.4 ; python_full_version < '3.14' and extra == 'tests'
- absl-py ; extra == 'tests'
- decorator ; extra == 'tests'
- joblib<1.3.0 ; extra == 'tests'
- - joblibspark ; extra == 'tests'
+ - joblibspark ; python_full_version < '3.14' and extra == 'tests'
- pytest ; extra == 'tests'
- pytest-datadir ; extra == 'tests'
- pytest-xdist ; extra == 'tests'
@@ -736,9 +1217,10 @@ packages:
- elasticsearch>=7.17.12,<8.0.0 ; extra == 'tests'
- faiss-cpu>=1.8.0.post1 ; extra == 'tests'
- h5py ; extra == 'tests'
+ - pylance ; extra == 'tests'
- jax>=0.3.14 ; sys_platform != 'win32' and extra == 'tests'
- jaxlib>=0.3.14 ; sys_platform != 'win32' and extra == 'tests'
- - lz4 ; extra == 'tests'
+ - lz4 ; python_full_version < '3.14' and extra == 'tests'
- moto[server] ; extra == 'tests'
- pyspark>=3.4 ; extra == 'tests'
- py7zr ; extra == 'tests'
@@ -746,7 +1228,7 @@ packages:
- sqlalchemy ; extra == 'tests'
- protobuf<4.0.0 ; extra == 'tests'
- tensorflow>=2.6.0 ; python_full_version < '3.10' and sys_platform != 'win32' and extra == 'tests'
- - tensorflow>=2.16.0 ; python_full_version >= '3.10' and sys_platform != 'win32' and extra == 'tests'
+ - tensorflow>=2.16.0 ; python_full_version >= '3.10' and python_full_version < '3.14' and sys_platform != 'win32' and extra == 'tests'
- tiktoken ; extra == 'tests'
- torch>=2.8.0 ; extra == 'tests'
- torchdata ; extra == 'tests'
@@ -754,21 +1236,23 @@ packages:
- zstandard ; extra == 'tests'
- polars[timezone]>=0.20.0 ; extra == 'tests'
- pillow>=9.4.0 ; extra == 'tests'
- - torchcodec>=0.7.0 ; extra == 'tests'
- - numba>=0.56.4 ; extra == 'tests-numpy2'
+ - torchcodec>=0.7.0 ; python_full_version < '3.14' and extra == 'tests'
+ - nibabel>=5.3.1 ; extra == 'tests'
+ - numba>=0.56.4 ; python_full_version < '3.14' and extra == 'tests-numpy2'
- absl-py ; extra == 'tests-numpy2'
- decorator ; extra == 'tests-numpy2'
- joblib<1.3.0 ; extra == 'tests-numpy2'
- - joblibspark ; extra == 'tests-numpy2'
+ - joblibspark ; python_full_version < '3.14' and extra == 'tests-numpy2'
- pytest ; extra == 'tests-numpy2'
- pytest-datadir ; extra == 'tests-numpy2'
- pytest-xdist ; extra == 'tests-numpy2'
- aiohttp ; extra == 'tests-numpy2'
- elasticsearch>=7.17.12,<8.0.0 ; extra == 'tests-numpy2'
- h5py ; extra == 'tests-numpy2'
+ - pylance ; extra == 'tests-numpy2'
- jax>=0.3.14 ; sys_platform != 'win32' and extra == 'tests-numpy2'
- jaxlib>=0.3.14 ; sys_platform != 'win32' and extra == 'tests-numpy2'
- - lz4 ; extra == 'tests-numpy2'
+ - lz4 ; python_full_version < '3.14' and extra == 'tests-numpy2'
- moto[server] ; extra == 'tests-numpy2'
- pyspark>=3.4 ; extra == 'tests-numpy2'
- py7zr ; extra == 'tests-numpy2'
@@ -782,7 +1266,8 @@ packages:
- zstandard ; extra == 'tests-numpy2'
- polars[timezone]>=0.20.0 ; extra == 'tests-numpy2'
- pillow>=9.4.0 ; extra == 'tests-numpy2'
- - torchcodec>=0.7.0 ; extra == 'tests-numpy2'
+ - torchcodec>=0.7.0 ; python_full_version < '3.14' and extra == 'tests-numpy2'
+ - nibabel>=5.3.1 ; extra == 'tests-numpy2'
- ruff>=0.3.0 ; extra == 'quality'
- tensorflow==2.12.0 ; extra == 'benchmarks'
- torch==2.0.1 ; extra == 'benchmarks'
@@ -791,15 +1276,17 @@ packages:
- torch ; extra == 'docs'
- tensorflow>=2.6.0 ; extra == 'docs'
- pdfplumber>=0.11.4 ; extra == 'pdfs'
- requires_python: '>=3.9.0'
-- pypi: https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl
+ - nibabel>=5.3.2 ; extra == 'nibabel'
+ - ipyniivue==2.4.2 ; extra == 'nibabel'
+ requires_python: '>=3.10.0'
+- pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl
name: dill
- version: 0.4.0
- sha256: 44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049
+ version: 0.4.1
+ sha256: 1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d
requires_dist:
- objgraph>=1.7.2 ; extra == 'graph'
- gprof2dot>=2022.7.29 ; extra == 'profile'
- requires_python: '>=3.8'
+ requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl
name: diskcache
version: 5.6.3
@@ -814,60 +1301,18 @@ packages:
version: 1.9.0
sha256: 7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2
requires_python: '>=3.6'
-- pypi: https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl
- name: docker
- version: 7.1.0
- sha256: c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0
- requires_dist:
- - pywin32>=304 ; sys_platform == 'win32'
- - requests>=2.26.0
- - urllib3>=1.26.0
- - coverage==7.2.7 ; extra == 'dev'
- - pytest-cov==4.1.0 ; extra == 'dev'
- - pytest-timeout==2.1.0 ; extra == 'dev'
- - pytest==7.4.2 ; extra == 'dev'
- - ruff==0.1.8 ; extra == 'dev'
- - myst-parser==0.18.0 ; extra == 'docs'
- - sphinx==5.1.1 ; extra == 'docs'
- - paramiko>=2.4.3 ; extra == 'ssh'
- - websocket-client>=1.3.0 ; extra == 'websockets'
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl
- name: docstring-parser
- version: 0.17.0
- sha256: cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708
- requires_dist:
- - pre-commit>=2.16.0 ; python_full_version >= '3.9' and extra == 'dev'
- - pydoctor>=25.4.0 ; extra == 'dev'
- - pytest ; extra == 'dev'
- - pydoctor>=25.4.0 ; extra == 'docs'
- - pytest ; extra == 'test'
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/a9/b6/f85666707b9f9ff94ada851cbaa6f1c91a0f5802aa5e498772251e1e7772/elementpath-5.0.4-py3-none-any.whl
- name: elementpath
- version: 5.0.4
- sha256: 75d6f31c614d57e50eb749fc50806e3102880cd1f6552da3f2265f8eb8d3bbc6
- requires_dist:
- - coverage ; extra == 'dev'
- - flake8 ; extra == 'dev'
- - lxml ; extra == 'dev'
- - lxml-stubs ; extra == 'dev'
- - memray ; extra == 'dev'
- - mypy ; extra == 'dev'
- - psutil ; extra == 'dev'
- - sphinx ; extra == 'dev'
- - tox ; extra == 'dev'
- - xmlschema>=4.0.1 ; extra == 'dev'
- - sphinx ; extra == 'docs'
- - readthedocs-sphinx-search ; extra == 'docs'
+- pypi: https://files.pythonhosted.org/packages/8f/d7/9322c609343d929e75e7e5e6255e614fcc67572cfd083959cdef3b7aad79/docutils-0.21.2-py3-none-any.whl
+ name: docutils
+ version: 0.21.2
+ sha256: dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/ce/31/55cd413eaccd39125368be33c46de24a1f639f2e12349b0361b4678f3915/eval_type_backport-0.2.2-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/cf/22/fdc2e30d43ff853720042fa15baa3e6122722be1a7950a98233ebb55cd71/eval_type_backport-0.3.1-py3-none-any.whl
name: eval-type-backport
- version: 0.2.2
- sha256: cb6ad7c393517f476f96d456d0412ea80f0a8cf96f6892834cd9340149111b0a
+ version: 0.3.1
+ sha256: 279ab641905e9f11129f56a8a78f493518515b83402b860f6f06dd7c011fdfa8
requires_dist:
- pytest ; extra == 'tests'
- requires_python: '>=3.8'
+ requires_python: '>=3.7'
- pypi: https://files.pythonhosted.org/packages/3e/af/3e990d8d4002bbc9342adb4facd59506e653da93b2417de0fa6027cb86b1/evaluate-0.4.6-py3-none-any.whl
name: evaluate
version: 0.4.6
@@ -962,29 +1407,25 @@ packages:
- six~=1.15.0 ; extra == 'tests'
- torch ; extra == 'torch'
requires_python: '>=3.8.0'
-- pypi: https://files.pythonhosted.org/packages/6a/2b/1b89e90a8635e5587ccdbbeb169c590672ce7637880f2c047482a0359950/fastuuid-0.13.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/16/6e/c0fb547eef61293153348f12e0f75a06abb322664b34a1573a7760501336/fastuuid-0.14.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: fastuuid
- version: 0.13.5
- sha256: f10c77b826738c1a27dcdaa92ea4dc1ec9d869748a99e1fde54f1379553d4854
+ version: 0.14.0
+ sha256: 808527f2407f58a76c916d6aa15d58692a4a019fdf8d4c32ac7ff303b7d7af09
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl
name: filelock
- version: 3.20.0
- sha256: 339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2
+ version: 3.25.2
+ sha256: ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/ee/1b/00a78aa2e8fbd63f9af08c9c19e6deb3d5d66b4dda677a0f61654680ee89/flatbuffers-25.9.23-py2.py3-none-any.whl
- name: flatbuffers
- version: 25.9.23
- sha256: 255538574d6cb6d0a79a17ec8bc0d30985913b87513a01cce8bcdb6b4c44d0e2
- pypi: https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
name: frozenlist
version: 1.8.0
sha256: 494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/47/71/70db47e4f6ce3e5c37a607355f80da8860a33226be640226ac52cb05ef2e/fsspec-2025.9.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl
name: fsspec
- version: 2025.9.0
- sha256: 530dc2a2af60a414a832059574df4a6e10cce927f6f4a78209390fe38955cfb7
+ version: 2026.2.0
+ sha256: 98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437
requires_dist:
- adlfs ; extra == 'abfs'
- adlfs ; extra == 'adl'
@@ -1008,7 +1449,7 @@ packages:
- dropbox ; extra == 'full'
- dropboxdrivefs ; extra == 'full'
- fusepy ; extra == 'full'
- - gcsfs ; extra == 'full'
+ - gcsfs>2024.2.0 ; extra == 'full'
- libarchive-c ; extra == 'full'
- ocifs ; extra == 'full'
- panel ; extra == 'full'
@@ -1016,11 +1457,11 @@ packages:
- pyarrow>=1 ; extra == 'full'
- pygit2 ; extra == 'full'
- requests ; extra == 'full'
- - s3fs ; extra == 'full'
+ - s3fs>2024.2.0 ; extra == 'full'
- smbprotocol ; extra == 'full'
- tqdm ; extra == 'full'
- fusepy ; extra == 'fuse'
- - gcsfs ; extra == 'gcs'
+ - gcsfs>2024.2.0 ; extra == 'gcs'
- pygit2 ; extra == 'git'
- requests ; extra == 'github'
- gcsfs ; extra == 'gs'
@@ -1029,7 +1470,7 @@ packages:
- aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'http'
- libarchive-c ; extra == 'libarchive'
- ocifs ; extra == 'oci'
- - s3fs ; extra == 's3'
+ - s3fs>2024.2.0 ; extra == 's3'
- paramiko ; extra == 'sftp'
- smbprotocol ; extra == 'smb'
- paramiko ; extra == 'ssh'
@@ -1050,6 +1491,7 @@ packages:
- xarray ; extra == 'test-downstream'
- adlfs ; extra == 'test-full'
- aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test-full'
+ - backports-zstd ; python_full_version < '3.14' and extra == 'test-full'
- cloudpickle ; extra == 'test-full'
- dask ; extra == 'test-full'
- distributed ; extra == 'test-full'
@@ -1065,7 +1507,7 @@ packages:
- notebook ; extra == 'test-full'
- numpy ; extra == 'test-full'
- ocifs ; extra == 'test-full'
- - pandas ; extra == 'test-full'
+ - pandas<3.0.0 ; extra == 'test-full'
- panel ; extra == 'test-full'
- paramiko ; extra == 'test-full'
- pyarrow ; extra == 'test-full'
@@ -1087,180 +1529,30 @@ packages:
- zarr ; extra == 'test-full'
- zstandard ; python_full_version < '3.14' and extra == 'test-full'
- tqdm ; extra == 'tqdm'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl
- name: fsspec
- version: 2025.10.0
- sha256: 7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl
+ name: google-auth
+ version: 2.49.1
+ sha256: 195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7
requires_dist:
- - adlfs ; extra == 'abfs'
- - adlfs ; extra == 'adl'
- - pyarrow>=1 ; extra == 'arrow'
- - dask ; extra == 'dask'
- - distributed ; extra == 'dask'
- - pre-commit ; extra == 'dev'
- - ruff>=0.5 ; extra == 'dev'
- - numpydoc ; extra == 'doc'
- - sphinx ; extra == 'doc'
- - sphinx-design ; extra == 'doc'
- - sphinx-rtd-theme ; extra == 'doc'
- - yarl ; extra == 'doc'
- - dropbox ; extra == 'dropbox'
- - dropboxdrivefs ; extra == 'dropbox'
- - requests ; extra == 'dropbox'
- - adlfs ; extra == 'full'
- - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'full'
- - dask ; extra == 'full'
- - distributed ; extra == 'full'
- - dropbox ; extra == 'full'
- - dropboxdrivefs ; extra == 'full'
- - fusepy ; extra == 'full'
- - gcsfs ; extra == 'full'
- - libarchive-c ; extra == 'full'
- - ocifs ; extra == 'full'
- - panel ; extra == 'full'
- - paramiko ; extra == 'full'
- - pyarrow>=1 ; extra == 'full'
- - pygit2 ; extra == 'full'
- - requests ; extra == 'full'
- - s3fs ; extra == 'full'
- - smbprotocol ; extra == 'full'
- - tqdm ; extra == 'full'
- - fusepy ; extra == 'fuse'
- - gcsfs ; extra == 'gcs'
- - pygit2 ; extra == 'git'
- - requests ; extra == 'github'
- - gcsfs ; extra == 'gs'
- - panel ; extra == 'gui'
- - pyarrow>=1 ; extra == 'hdfs'
- - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'http'
- - libarchive-c ; extra == 'libarchive'
- - ocifs ; extra == 'oci'
- - s3fs ; extra == 's3'
- - paramiko ; extra == 'sftp'
- - smbprotocol ; extra == 'smb'
- - paramiko ; extra == 'ssh'
- - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test'
- - numpy ; extra == 'test'
- - pytest ; extra == 'test'
- - pytest-asyncio!=0.22.0 ; extra == 'test'
- - pytest-benchmark ; extra == 'test'
- - pytest-cov ; extra == 'test'
- - pytest-mock ; extra == 'test'
- - pytest-recording ; extra == 'test'
- - pytest-rerunfailures ; extra == 'test'
- - requests ; extra == 'test'
- - aiobotocore>=2.5.4,<3.0.0 ; extra == 'test-downstream'
- - dask[dataframe,test] ; extra == 'test-downstream'
- - moto[server]>4,<5 ; extra == 'test-downstream'
- - pytest-timeout ; extra == 'test-downstream'
- - xarray ; extra == 'test-downstream'
- - adlfs ; extra == 'test-full'
- - aiohttp!=4.0.0a0,!=4.0.0a1 ; extra == 'test-full'
- - cloudpickle ; extra == 'test-full'
- - dask ; extra == 'test-full'
- - distributed ; extra == 'test-full'
- - dropbox ; extra == 'test-full'
- - dropboxdrivefs ; extra == 'test-full'
- - fastparquet ; extra == 'test-full'
- - fusepy ; extra == 'test-full'
- - gcsfs ; extra == 'test-full'
- - jinja2 ; extra == 'test-full'
- - kerchunk ; extra == 'test-full'
- - libarchive-c ; extra == 'test-full'
- - lz4 ; extra == 'test-full'
- - notebook ; extra == 'test-full'
- - numpy ; extra == 'test-full'
- - ocifs ; extra == 'test-full'
- - pandas ; extra == 'test-full'
- - panel ; extra == 'test-full'
- - paramiko ; extra == 'test-full'
- - pyarrow ; extra == 'test-full'
- - pyarrow>=1 ; extra == 'test-full'
- - pyftpdlib ; extra == 'test-full'
- - pygit2 ; extra == 'test-full'
- - pytest ; extra == 'test-full'
- - pytest-asyncio!=0.22.0 ; extra == 'test-full'
- - pytest-benchmark ; extra == 'test-full'
- - pytest-cov ; extra == 'test-full'
- - pytest-mock ; extra == 'test-full'
- - pytest-recording ; extra == 'test-full'
- - pytest-rerunfailures ; extra == 'test-full'
- - python-snappy ; extra == 'test-full'
- - requests ; extra == 'test-full'
- - smbprotocol ; extra == 'test-full'
- - tqdm ; extra == 'test-full'
- - urllib3 ; extra == 'test-full'
- - zarr ; extra == 'test-full'
- - zstandard ; python_full_version < '3.14' and extra == 'test-full'
- - tqdm ; extra == 'tqdm'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/77/ad/f73cf9fe9bd95918502b270e3ddb8764e4c900b3bbd7782b90c56fac14bb/google_api_core-2.26.0-py3-none-any.whl
- name: google-api-core
- version: 2.26.0
- sha256: 2b204bd0da2c81f918e3582c48458e24c11771f987f6258e6e227212af78f3ed
- requires_dist:
- - googleapis-common-protos>=1.56.2,<2.0.0
- - protobuf>=3.19.5,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- - proto-plus>=1.22.3,<2.0.0
- - proto-plus>=1.25.0,<2.0.0 ; python_full_version >= '3.13'
- - google-auth>=2.14.1,<3.0.0
- - requests>=2.18.0,<3.0.0
- - google-auth[aiohttp]>=2.35.0,<3.0.0 ; extra == 'async-rest'
- - grpcio>=1.33.2,<2.0.0 ; extra == 'grpc'
- - grpcio>=1.49.1,<2.0.0 ; python_full_version >= '3.11' and extra == 'grpc'
- - grpcio>=1.75.1,<2.0.0 ; python_full_version >= '3.14' and extra == 'grpc'
- - grpcio-status>=1.33.2,<2.0.0 ; extra == 'grpc'
- - grpcio-status>=1.49.1,<2.0.0 ; python_full_version >= '3.11' and extra == 'grpc'
- - grpcio-status>=1.75.1,<2.0.0 ; python_full_version >= '3.14' and extra == 'grpc'
- - grpcio-gcp>=0.2.2,<1.0.0 ; extra == 'grpcgcp'
- - grpcio-gcp>=0.2.2,<1.0.0 ; extra == 'grpcio-gcp'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/f4/38/d25ae1565103a545cf18207a5dec09a6d39ad88e5b0399a2430e9edb0550/google_api_python_client-2.184.0-py3-none-any.whl
- name: google-api-python-client
- version: 2.184.0
- sha256: 15a18d02f42de99416921c77be235d12ead474e474a1abc348b01a2b92633fa4
- requires_dist:
- - httplib2>=0.19.0,<1.0.0
- - google-auth>=1.32.0,!=2.24.0,!=2.25.0,<3.0.0
- - google-auth-httplib2>=0.2.0,<1.0.0
- - google-api-core>=1.31.5,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.0,<3.0.0
- - uritemplate>=3.0.1,<5
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/be/a4/7319a2a8add4cc352be9e3efeff5e2aacee917c85ca2fa1647e29089983c/google_auth-2.41.1-py2.py3-none-any.whl
- name: google-auth
- version: 2.41.1
- sha256: 754843be95575b9a19c604a848a41be03f7f2afd8c019f716dc1f51ee41c639d
- requires_dist:
- - cachetools>=2.0.0,<7.0
- pyasn1-modules>=0.2.1
- - rsa>=3.1.4,<5
+ - cryptography>=38.0.3
+ - cryptography>=38.0.3 ; extra == 'cryptography'
- aiohttp>=3.6.2,<4.0.0 ; extra == 'aiohttp'
- requests>=2.20.0,<3.0.0 ; extra == 'aiohttp'
- - cryptography ; extra == 'enterprise-cert'
- pyopenssl ; extra == 'enterprise-cert'
- pyopenssl>=20.0.0 ; extra == 'pyopenssl'
- - cryptography>=38.0.3 ; extra == 'pyopenssl'
- - cryptography<39.0.0 ; python_full_version < '3.8' and extra == 'pyopenssl'
- pyjwt>=2.0 ; extra == 'pyjwt'
- - cryptography>=38.0.3 ; extra == 'pyjwt'
- - cryptography<39.0.0 ; python_full_version < '3.8' and extra == 'pyjwt'
- pyu2f>=0.1.5 ; extra == 'reauth'
- requests>=2.20.0,<3.0.0 ; extra == 'requests'
- grpcio ; extra == 'testing'
- flask ; extra == 'testing'
- freezegun ; extra == 'testing'
- - mock ; extra == 'testing'
- - oauth2client ; extra == 'testing'
- pyjwt>=2.0 ; extra == 'testing'
- - cryptography>=38.0.3 ; extra == 'testing'
- - cryptography<39.0.0 ; python_full_version < '3.8' and extra == 'testing'
- pytest ; extra == 'testing'
- pytest-cov ; extra == 'testing'
- pytest-localserver ; extra == 'testing'
- pyopenssl>=20.0.0 ; extra == 'testing'
- - cryptography>=38.0.3 ; extra == 'testing'
- - cryptography<39.0.0 ; python_full_version < '3.8' and extra == 'testing'
- pyu2f>=0.1.5 ; extra == 'testing'
- responses ; extra == 'testing'
- urllib3 ; extra == 'testing'
@@ -1273,413 +1565,25 @@ packages:
- aiohttp<3.10.0 ; extra == 'testing'
- urllib3 ; extra == 'urllib3'
- packaging ; extra == 'urllib3'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/be/8a/fe34d2f3f9470a27b01c9e76226965863f153d5fbe276f83608562e49c04/google_auth_httplib2-0.2.0-py2.py3-none-any.whl
- name: google-auth-httplib2
- version: 0.2.0
- sha256: b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d
- requires_dist:
- - google-auth
- - httplib2>=0.19.0
-- pypi: https://files.pythonhosted.org/packages/bd/f6/806b39f86f912133a3071ffa9ff99801a12868216069e26c83a48943116b/google_cloud_aiplatform-1.121.0-py2.py3-none-any.whl
- name: google-cloud-aiplatform
- version: 1.121.0
- sha256: 1e7105dfd17963207e966550c9544264508efdfded29cf4924c5b86ff4a22efd
- requires_dist:
- - google-api-core[grpc]>=1.34.1,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,<3.0.0
- - google-auth>=2.14.1,<3.0.0
- - proto-plus>=1.22.3,<2.0.0
- - protobuf>=3.20.2,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- - packaging>=14.3
- - google-cloud-storage>=1.32.0,<3.0.0
- - google-cloud-bigquery>=1.15.0,!=3.20.0,<4.0.0
- - google-cloud-resource-manager>=1.3.3,<3.0.0
- - shapely<3.0.0
- - google-genai>=1.37.0,<2.0.0
- - pydantic<3
- - typing-extensions
- - docstring-parser<1
- - requests>=2.28.1 ; extra == 'endpoint'
- - requests-toolbelt<=1.0.0 ; extra == 'endpoint'
- - ray[default]>=2.5,<=2.47.1 ; python_full_version == '3.11.*' and extra == 'full'
- - jsonschema ; extra == 'full'
- - explainable-ai-sdk>=1.0.0 ; extra == 'full'
- - ruamel-yaml ; extra == 'full'
- - httpx>=0.23.0,<=0.28.1 ; extra == 'full'
- - pyyaml ; extra == 'full'
- - scikit-learn<1.6.0 ; python_full_version < '3.11' and extra == 'full'
- - pyarrow>=10.0.1 ; python_full_version == '3.11.*' and extra == 'full'
- - docker>=5.0.3 ; extra == 'full'
- - google-vizier>=0.1.6 ; extra == 'full'
- - pyarrow>=6.0.1 ; extra == 'full'
- - google-cloud-bigquery-storage ; extra == 'full'
- - google-cloud-bigquery ; extra == 'full'
- - uvicorn[standard]>=0.16.0 ; extra == 'full'
- - pyyaml>=5.3.1,<7 ; extra == 'full'
- - starlette>=0.17.1 ; extra == 'full'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'full'
- - requests>=2.28.1 ; extra == 'full'
- - scikit-learn ; python_full_version >= '3.11' and extra == 'full'
- - litellm>=1.72.4,<=1.76.3 ; extra == 'full'
- - werkzeug>=2.0.0,<4.0.0 ; extra == 'full'
- - fastapi>=0.71.0,<=0.114.0 ; extra == 'full'
- - ray[default]>=2.4,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.0,!=2.9.1,!=2.9.2,!=2.10.*,!=2.11.*,!=2.12.*,!=2.13.*,!=2.14.*,!=2.15.*,!=2.16.*,!=2.17.*,!=2.18.*,!=2.19.*,!=2.20.*,!=2.21.*,!=2.22.*,!=2.23.*,!=2.24.*,!=2.25.*,!=2.26.*,!=2.27.*,!=2.28.*,!=2.29.*,!=2.30.*,!=2.31.*,!=2.32.*,!=2.34.*,!=2.35.*,!=2.36.*,!=2.37.*,!=2.38.*,!=2.39.*,!=2.40.*,!=2.41.*,<=2.42.0 ; python_full_version < '3.11' and extra == 'full'
- - pyarrow>=3.0.0,<8.0.0 ; python_full_version < '3.11' and extra == 'full'
- - numpy>=1.15.0 ; extra == 'full'
- - tensorboard-plugin-profile>=2.4.0,<2.18.0 ; extra == 'full'
- - pyarrow>=14.0.0 ; python_full_version >= '3.12' and extra == 'full'
- - immutabledict ; extra == 'full'
- - urllib3>=1.21.1,<1.27 ; extra == 'full'
- - tqdm>=4.23.0 ; extra == 'full'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'full'
- - mlflow>=1.27.0,<=2.16.0 ; extra == 'full'
- - pandas>=1.0.0 ; extra == 'full'
- - lit-nlp==0.4.0 ; extra == 'full'
- - requests-toolbelt<=1.0.0 ; extra == 'full'
- - pandas>=1.0.0 ; extra == 'metadata'
- - numpy>=1.15.0 ; extra == 'metadata'
- - tensorboard-plugin-profile>=2.4.0,<2.18.0 ; extra == 'tensorboard'
- - werkzeug>=2.0.0,<4.0.0 ; extra == 'tensorboard'
- - ray[default]>=2.5,<=2.47.1 ; python_full_version == '3.11.*' and extra == 'testing'
- - jsonschema ; extra == 'testing'
- - explainable-ai-sdk>=1.0.0 ; extra == 'testing'
- - ruamel-yaml ; extra == 'testing'
- - httpx>=0.23.0,<=0.28.1 ; extra == 'testing'
- - pyyaml ; extra == 'testing'
- - scikit-learn<1.6.0 ; python_full_version < '3.11' and extra == 'testing'
- - pyarrow>=10.0.1 ; python_full_version == '3.11.*' and extra == 'testing'
- - docker>=5.0.3 ; extra == 'testing'
- - google-vizier>=0.1.6 ; extra == 'testing'
- - pyarrow>=6.0.1 ; extra == 'testing'
- - google-cloud-bigquery-storage ; extra == 'testing'
- - google-cloud-bigquery ; extra == 'testing'
- - uvicorn[standard]>=0.16.0 ; extra == 'testing'
- - pyyaml>=5.3.1,<7 ; extra == 'testing'
- - starlette>=0.17.1 ; extra == 'testing'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'testing'
- - requests>=2.28.1 ; extra == 'testing'
- - scikit-learn ; python_full_version >= '3.11' and extra == 'testing'
- - litellm>=1.72.4,<=1.76.3 ; extra == 'testing'
- - werkzeug>=2.0.0,<4.0.0 ; extra == 'testing'
- - fastapi>=0.71.0,<=0.114.0 ; extra == 'testing'
- - ray[default]>=2.4,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.0,!=2.9.1,!=2.9.2,!=2.10.*,!=2.11.*,!=2.12.*,!=2.13.*,!=2.14.*,!=2.15.*,!=2.16.*,!=2.17.*,!=2.18.*,!=2.19.*,!=2.20.*,!=2.21.*,!=2.22.*,!=2.23.*,!=2.24.*,!=2.25.*,!=2.26.*,!=2.27.*,!=2.28.*,!=2.29.*,!=2.30.*,!=2.31.*,!=2.32.*,!=2.34.*,!=2.35.*,!=2.36.*,!=2.37.*,!=2.38.*,!=2.39.*,!=2.40.*,!=2.41.*,<=2.42.0 ; python_full_version < '3.11' and extra == 'testing'
- - pyarrow>=3.0.0,<8.0.0 ; python_full_version < '3.11' and extra == 'testing'
- - numpy>=1.15.0 ; extra == 'testing'
- - tensorboard-plugin-profile>=2.4.0,<2.18.0 ; extra == 'testing'
- - pyarrow>=14.0.0 ; python_full_version >= '3.12' and extra == 'testing'
- - immutabledict ; extra == 'testing'
- - urllib3>=1.21.1,<1.27 ; extra == 'testing'
- - tqdm>=4.23.0 ; extra == 'testing'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'testing'
- - mlflow>=1.27.0,<=2.16.0 ; extra == 'testing'
- - pandas>=1.0.0 ; extra == 'testing'
- - lit-nlp==0.4.0 ; extra == 'testing'
- - requests-toolbelt<=1.0.0 ; extra == 'testing'
- - tensorboard-plugin-profile>=2.4.0,<2.18.0 ; extra == 'testing'
- - werkzeug>=2.0.0,<4.0.0 ; extra == 'testing'
- - sentencepiece>=0.2.0 ; extra == 'testing'
- - nltk ; extra == 'testing'
- - google-vizier>=0.1.6 ; extra == 'testing'
- - aiohttp ; extra == 'testing'
- - bigframes ; python_full_version >= '3.10' and extra == 'testing'
- - google-api-core>=2.11,<3.0.0 ; extra == 'testing'
- - grpcio-testing ; extra == 'testing'
- - ipython ; extra == 'testing'
- - kfp>=2.6.0,<3.0.0 ; extra == 'testing'
- - pytest-asyncio ; extra == 'testing'
- - pytest-xdist ; extra == 'testing'
- - scikit-learn<1.6.0 ; python_full_version < '3.11' and extra == 'testing'
- - scikit-learn ; python_full_version >= '3.11' and extra == 'testing'
- - tensorflow==2.14.1 ; python_full_version < '3.12' and extra == 'testing'
- - tensorflow==2.19.0 ; python_full_version >= '3.12' and extra == 'testing'
- - protobuf<=5.29.4 ; extra == 'testing'
- - torch>=2.0.0,<2.1.0 ; python_full_version < '3.12' and extra == 'testing'
- - torch>=2.2.0 ; python_full_version >= '3.12' and extra == 'testing'
- - requests-toolbelt<=1.0.0 ; extra == 'testing'
- - immutabledict ; extra == 'testing'
- - xgboost ; extra == 'testing'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'xai'
- - tensorflow>=2.3.0,<3.0.0 ; extra == 'lit'
- - pandas>=1.0.0 ; extra == 'lit'
- - lit-nlp==0.4.0 ; extra == 'lit'
- - explainable-ai-sdk>=1.0.0 ; extra == 'lit'
- - tensorboard-plugin-profile>=2.4.0,<2.18.0 ; extra == 'cloud-profiler'
- - werkzeug>=2.0.0,<4.0.0 ; extra == 'cloud-profiler'
- - pyyaml>=5.3.1,<7 ; extra == 'pipelines'
- - google-vizier>=0.1.6 ; extra == 'vizier'
- - docker>=5.0.3 ; extra == 'prediction'
- - fastapi>=0.71.0,<=0.114.0 ; extra == 'prediction'
- - httpx>=0.23.0,<=0.28.1 ; extra == 'prediction'
- - starlette>=0.17.1 ; extra == 'prediction'
- - uvicorn[standard]>=0.16.0 ; extra == 'prediction'
- - pyarrow>=3.0.0,<8.0.0 ; python_full_version < '3.11' and extra == 'datasets'
- - pyarrow>=10.0.1 ; python_full_version == '3.11.*' and extra == 'datasets'
- - pyarrow>=14.0.0 ; python_full_version >= '3.12' and extra == 'datasets'
- - urllib3>=1.21.1,<1.27 ; extra == 'private-endpoints'
- - requests>=2.28.1 ; extra == 'private-endpoints'
- - mlflow>=1.27.0,<=2.16.0 ; extra == 'autologging'
- - ray[default]>=2.4,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.0,!=2.9.1,!=2.9.2,!=2.10.*,!=2.11.*,!=2.12.*,!=2.13.*,!=2.14.*,!=2.15.*,!=2.16.*,!=2.17.*,!=2.18.*,!=2.19.*,!=2.20.*,!=2.21.*,!=2.22.*,!=2.23.*,!=2.24.*,!=2.25.*,!=2.26.*,!=2.27.*,!=2.28.*,!=2.29.*,!=2.30.*,!=2.31.*,!=2.32.*,!=2.34.*,!=2.35.*,!=2.36.*,!=2.37.*,!=2.38.*,!=2.39.*,!=2.40.*,!=2.41.*,<=2.42.0 ; python_full_version < '3.11' and extra == 'ray'
- - ray[default]>=2.5,<=2.47.1 ; python_full_version == '3.11.*' and extra == 'ray'
- - google-cloud-bigquery-storage ; extra == 'ray'
- - google-cloud-bigquery ; extra == 'ray'
- - pandas>=1.0.0 ; extra == 'ray'
- - pyarrow>=6.0.1 ; extra == 'ray'
- - immutabledict ; extra == 'ray'
- - ray[default]>=2.4,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.0,!=2.9.1,!=2.9.2,!=2.10.*,!=2.11.*,!=2.12.*,!=2.13.*,!=2.14.*,!=2.15.*,!=2.16.*,!=2.17.*,!=2.18.*,!=2.19.*,!=2.20.*,!=2.21.*,!=2.22.*,!=2.23.*,!=2.24.*,!=2.25.*,!=2.26.*,!=2.27.*,!=2.28.*,!=2.29.*,!=2.30.*,!=2.31.*,!=2.32.*,!=2.34.*,!=2.35.*,!=2.36.*,!=2.37.*,!=2.38.*,!=2.39.*,!=2.40.*,!=2.41.*,<=2.42.0 ; python_full_version < '3.11' and extra == 'ray-testing'
- - ray[default]>=2.5,<=2.47.1 ; python_full_version == '3.11.*' and extra == 'ray-testing'
- - google-cloud-bigquery-storage ; extra == 'ray-testing'
- - google-cloud-bigquery ; extra == 'ray-testing'
- - pandas>=1.0.0 ; extra == 'ray-testing'
- - pyarrow>=6.0.1 ; extra == 'ray-testing'
- - immutabledict ; extra == 'ray-testing'
- - pytest-xdist ; extra == 'ray-testing'
- - ray[train] ; extra == 'ray-testing'
- - scikit-learn<1.6.0 ; extra == 'ray-testing'
- - tensorflow ; extra == 'ray-testing'
- - torch>=2.0.0,<2.1.0 ; extra == 'ray-testing'
- - xgboost ; extra == 'ray-testing'
- - xgboost-ray ; extra == 'ray-testing'
- - google-adk>=1.0.0,<2.0.0 ; extra == 'adk'
- - opentelemetry-instrumentation-google-genai>=0.3b0,<1.0.0 ; extra == 'adk'
- - cloudpickle>=3.0,<4.0 ; extra == 'reasoningengine'
- - google-cloud-trace<2 ; extra == 'reasoningengine'
- - opentelemetry-sdk<2 ; extra == 'reasoningengine'
- - opentelemetry-exporter-gcp-trace<2 ; extra == 'reasoningengine'
- - pydantic>=2.11.1,<3 ; extra == 'reasoningengine'
- - typing-extensions ; extra == 'reasoningengine'
- - packaging>=24.0 ; extra == 'agent-engines'
- - cloudpickle>=3.0,<4.0 ; extra == 'agent-engines'
- - google-cloud-trace<2 ; extra == 'agent-engines'
- - google-cloud-logging<4 ; extra == 'agent-engines'
- - opentelemetry-sdk<2 ; extra == 'agent-engines'
- - opentelemetry-exporter-gcp-trace<2 ; extra == 'agent-engines'
- - pydantic>=2.11.1,<3 ; extra == 'agent-engines'
- - typing-extensions ; extra == 'agent-engines'
- - pandas>=1.0.0 ; extra == 'evaluation'
- - tqdm>=4.23.0 ; extra == 'evaluation'
- - scikit-learn<1.6.0 ; python_full_version < '3.11' and extra == 'evaluation'
- - scikit-learn ; python_full_version >= '3.11' and extra == 'evaluation'
- - jsonschema ; extra == 'evaluation'
- - ruamel-yaml ; extra == 'evaluation'
- - pyyaml ; extra == 'evaluation'
- - litellm>=1.72.4,<=1.76.3 ; extra == 'evaluation'
- - langchain>=0.3,<0.4 ; extra == 'langchain'
- - langchain-core>=0.3,<0.4 ; extra == 'langchain'
- - langchain-google-vertexai>=2.0.22,<3 ; extra == 'langchain'
- - langgraph>=0.2.45,<0.4 ; extra == 'langchain'
- - openinference-instrumentation-langchain>=0.1.19,<0.2 ; extra == 'langchain'
- - cloudpickle>=3.0,<4.0 ; extra == 'langchain-testing'
- - langgraph>=0.2.45,<0.4 ; extra == 'langchain-testing'
- - langchain>=0.3,<0.4 ; extra == 'langchain-testing'
- - langchain-core>=0.3,<0.4 ; extra == 'langchain-testing'
- - opentelemetry-exporter-gcp-trace<2 ; extra == 'langchain-testing'
- - pydantic>=2.11.1,<3 ; extra == 'langchain-testing'
- - google-cloud-trace<2 ; extra == 'langchain-testing'
- - absl-py ; extra == 'langchain-testing'
- - openinference-instrumentation-langchain>=0.1.19,<0.2 ; extra == 'langchain-testing'
- - pytest-xdist ; extra == 'langchain-testing'
- - opentelemetry-sdk<2 ; extra == 'langchain-testing'
- - typing-extensions ; extra == 'langchain-testing'
- - langchain-google-vertexai>=2.0.22,<3 ; extra == 'langchain-testing'
- - sentencepiece>=0.2.0 ; extra == 'tokenization'
- - ag2[gemini] ; extra == 'ag2'
- - openinference-instrumentation-autogen>=0.1.6,<0.2 ; extra == 'ag2'
- - cloudpickle>=3.0,<4.0 ; extra == 'ag2-testing'
- - opentelemetry-exporter-gcp-trace<2 ; extra == 'ag2-testing'
- - pydantic>=2.11.1,<3 ; extra == 'ag2-testing'
- - google-cloud-trace<2 ; extra == 'ag2-testing'
- - openinference-instrumentation-autogen>=0.1.6,<0.2 ; extra == 'ag2-testing'
- - absl-py ; extra == 'ag2-testing'
- - pytest-xdist ; extra == 'ag2-testing'
- - opentelemetry-sdk<2 ; extra == 'ag2-testing'
- - typing-extensions ; extra == 'ag2-testing'
- - ag2[gemini] ; extra == 'ag2-testing'
- - llama-index ; extra == 'llama-index'
- - llama-index-llms-google-genai ; extra == 'llama-index'
- - openinference-instrumentation-llama-index>=3.0,<4.0 ; extra == 'llama-index'
- - cloudpickle>=3.0,<4.0 ; extra == 'llama-index-testing'
- - opentelemetry-exporter-gcp-trace<2 ; extra == 'llama-index-testing'
- - pydantic>=2.11.1,<3 ; extra == 'llama-index-testing'
- - google-cloud-trace<2 ; extra == 'llama-index-testing'
- - llama-index-llms-google-genai ; extra == 'llama-index-testing'
- - absl-py ; extra == 'llama-index-testing'
- - pytest-xdist ; extra == 'llama-index-testing'
- - llama-index ; extra == 'llama-index-testing'
- - opentelemetry-sdk<2 ; extra == 'llama-index-testing'
- - typing-extensions ; extra == 'llama-index-testing'
- - openinference-instrumentation-llama-index>=3.0,<4.0 ; extra == 'llama-index-testing'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/39/3c/c8cada9ec282b29232ed9aed5a0b5cca6cf5367cb2ffa8ad0d2583d743f1/google_cloud_bigquery-3.38.0-py3-none-any.whl
- name: google-cloud-bigquery
- version: 3.38.0
- sha256: e06e93ff7b245b239945ef59cb59616057598d369edac457ebf292bd61984da6
- requires_dist:
- - google-api-core[grpc]>=2.11.1,<3.0.0
- - google-auth>=2.14.1,<3.0.0
- - google-cloud-core>=2.4.1,<3.0.0
- - google-resumable-media>=2.0.0,<3.0.0
- - packaging>=24.2.0
- - python-dateutil>=2.8.2,<3.0.0
- - requests>=2.21.0,<3.0.0
- - google-cloud-bigquery-storage>=2.18.0,<3.0.0 ; extra == 'bqstorage'
- - grpcio>=1.47.0,<2.0.0 ; extra == 'bqstorage'
- - grpcio>=1.49.1,<2.0.0 ; python_full_version >= '3.11' and extra == 'bqstorage'
- - pyarrow>=4.0.0 ; extra == 'bqstorage'
- - pandas>=1.3.0 ; extra == 'pandas'
- - pandas-gbq>=0.26.1 ; extra == 'pandas'
- - grpcio>=1.47.0,<2.0.0 ; extra == 'pandas'
- - grpcio>=1.49.1,<2.0.0 ; python_full_version >= '3.11' and extra == 'pandas'
- - pyarrow>=3.0.0 ; extra == 'pandas'
- - db-dtypes>=1.0.4,<2.0.0 ; extra == 'pandas'
- - ipywidgets>=7.7.1 ; extra == 'ipywidgets'
- - ipykernel>=6.2.0 ; extra == 'ipywidgets'
- - geopandas>=0.9.0,<2.0.0 ; extra == 'geopandas'
- - shapely>=1.8.4,<3.0.0 ; extra == 'geopandas'
- - ipython>=7.23.1 ; extra == 'ipython'
- - bigquery-magics>=0.6.0 ; extra == 'ipython'
- - matplotlib>=3.7.1,<=3.9.2 ; python_full_version == '3.9.*' and extra == 'matplotlib'
- - matplotlib>=3.10.3 ; python_full_version >= '3.10' and extra == 'matplotlib'
- - tqdm>=4.23.4,<5.0.0 ; extra == 'tqdm'
- - opentelemetry-api>=1.1.0 ; extra == 'opentelemetry'
- - opentelemetry-sdk>=1.1.0 ; extra == 'opentelemetry'
- - opentelemetry-instrumentation>=0.20b0 ; extra == 'opentelemetry'
- - proto-plus>=1.22.3,<2.0.0 ; extra == 'bigquery-v2'
- - protobuf>=3.20.2,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0 ; extra == 'bigquery-v2'
- - google-cloud-bigquery[bigquery-v2,bqstorage,geopandas,ipython,ipywidgets,matplotlib,opentelemetry,pandas,tqdm] ; extra == 'all'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/40/86/bda7241a8da2d28a754aad2ba0f6776e35b67e37c36ae0c45d49370f1014/google_cloud_core-2.4.3-py2.py3-none-any.whl
- name: google-cloud-core
- version: 2.4.3
- sha256: 5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e
- requires_dist:
- - google-api-core>=1.31.6,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.0,<3.0.0.dev0
- - google-auth>=1.25.0,<3.0.dev0
- - importlib-metadata>1.0.0 ; python_full_version < '3.8'
- - grpcio>=1.38.0,<2.0.dev0 ; extra == 'grpc'
- - grpcio-status>=1.38.0,<2.0.dev0 ; extra == 'grpc'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/24/92/e3c4850d5d1dd83da7538f1ee91baac9d4bc849e96899bc23ddbe20f15f4/google_cloud_documentai-3.6.0-py3-none-any.whl
- name: google-cloud-documentai
- version: 3.6.0
- sha256: 4c630002eec653313cde75892c92a5482c0d4629997279b3244d356261f9b5f5
- requires_dist:
- - google-api-core[grpc]>=1.34.1,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,!=2.10.*,<3.0.0
- - google-auth>=2.14.1,!=2.24.0,!=2.25.0,<3.0.0
- - proto-plus>=1.22.3,<2.0.0
- - proto-plus>=1.25.0,<2.0.0 ; python_full_version >= '3.13'
- - protobuf>=3.20.2,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/b1/ea/a92631c358da377af34d3a9682c97af83185c2d66363d5939ab4a1169a7f/google_cloud_resource_manager-1.14.2-py3-none-any.whl
- name: google-cloud-resource-manager
- version: 1.14.2
- sha256: d0fa954dedd1d2b8e13feae9099c01b8aac515b648e612834f9942d2795a9900
- requires_dist:
- - google-api-core[grpc]>=1.34.1,!=2.0.*,!=2.1.*,!=2.2.*,!=2.3.*,!=2.4.*,!=2.5.*,!=2.6.*,!=2.7.*,!=2.8.*,!=2.9.*,!=2.10.*,<3.0.0
- - google-auth>=2.14.1,!=2.24.0,!=2.25.0,<3.0.0
- - proto-plus>=1.22.3,<2.0.0
- - protobuf>=3.20.2,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- - grpc-google-iam-v1>=0.14.0,<1.0.0
- - proto-plus>=1.25.0,<2.0.0 ; python_full_version >= '3.13'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/d5/94/6db383d8ee1adf45dc6c73477152b82731fa4c4a46d9c1932cc8757e0fd4/google_cloud_storage-2.19.0-py2.py3-none-any.whl
- name: google-cloud-storage
- version: 2.19.0
- sha256: aeb971b5c29cf8ab98445082cbfe7b161a1f48ed275822f59ed3f1524ea54fba
- requires_dist:
- - google-auth>=2.26.1,<3.0.dev0
- - google-api-core>=2.15.0,<3.0.0.dev0
- - google-cloud-core>=2.3.0,<3.0.dev0
- - google-resumable-media>=2.7.2
- - requests>=2.18.0,<3.0.0.dev0
- - google-crc32c>=1.0,<2.0.dev0
- - protobuf<6.0.0.dev0 ; extra == 'protobuf'
- - opentelemetry-api>=1.1.0 ; extra == 'tracing'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/3b/a5/7279055cf004561894ed3a7bfdf5bf90a53f28fadd01af7cd166e88ddf16/google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- name: google-crc32c
- version: 1.7.1
- sha256: b6d86616faaea68101195c6bdc40c494e4d76f41e07a37ffdef270879c15fb65
- requires_dist:
- - importlib-resources>=1.3 ; python_full_version < '3.9' and os_name == 'nt'
- - pytest ; extra == 'testing'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/11/8f/922116dabe3d0312f08903d324db6ac9d406832cf57707550bc61151d91b/google_genai-1.45.0-py3-none-any.whl
- name: google-genai
- version: 1.45.0
- sha256: e755295063e5fd5a4c44acff782a569e37fa8f76a6c75d0ede3375c70d916b7f
- requires_dist:
- - anyio>=4.8.0,<5.0.0
- - google-auth>=2.14.1,<3.0.0
- - httpx>=0.28.1,<1.0.0
- - pydantic>=2.0.0,<3.0.0
- - requests>=2.28.1,<3.0.0
- - tenacity>=8.2.3,<9.2.0
- - websockets>=13.0.0,<15.1.0
- - typing-extensions>=4.11.0,<5.0.0
- - aiohttp<4.0.0 ; extra == 'aiohttp'
- - sentencepiece>=0.2.0 ; extra == 'local-tokenizer'
- - protobuf ; extra == 'local-tokenizer'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/82/35/b8d3baf8c46695858cb9d8835a53baa1eeb9906ddaf2f728a5f5b640fd1e/google_resumable_media-2.7.2-py2.py3-none-any.whl
- name: google-resumable-media
- version: 2.7.2
- sha256: 3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa
- requires_dist:
- - google-crc32c>=1.0,<2.0.dev0
- - aiohttp>=3.6.2,<4.0.0.dev0 ; extra == 'aiohttp'
- - google-auth>=1.22.0,<2.0.dev0 ; extra == 'aiohttp'
- - requests>=2.18.0,<3.0.0.dev0 ; extra == 'requests'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl
+ - rsa>=3.1.4,<5 ; extra == 'rsa'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/69/28/23eea8acd65972bbfe295ce3666b28ac510dfcb115fac089d3edb0feb00a/googleapis_common_protos-1.73.0-py3-none-any.whl
name: googleapis-common-protos
- version: 1.70.0
- sha256: b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8
+ version: 1.73.0
+ sha256: dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8
requires_dist:
- protobuf>=3.20.2,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- grpcio>=1.44.0,<2.0.0 ; extra == 'grpc'
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/4a/bd/330a1bbdb1afe0b96311249e699b6dc9cfc17916394fd4503ac5aca2514b/grpc_google_iam_v1-0.14.3-py3-none-any.whl
- name: grpc-google-iam-v1
- version: 0.14.3
- sha256: 7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6
- requires_dist:
- - grpcio>=1.44.0,<2.0.0
- - googleapis-common-protos[grpc]>=1.56.0,<2.0.0
- - protobuf>=3.20.2,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5,<7.0.0
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/1e/9c/eda9fe57f2b84343d44c1b66cf3831c973ba29b078b16a27d4587a1fdd47/grpcio-1.75.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- name: grpcio
- version: 1.75.1
- sha256: 7d4fa6ccc3ec2e68a04f7b883d354d7fea22a34c44ce535a2f0c0049cf626ddf
- requires_dist:
- - typing-extensions~=4.12
- - grpcio-tools>=1.75.1 ; extra == 'protobuf'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/d8/ad/6f414bb0b36eee20d93af6907256f208ffcda992ae6d3d7b6a778afe31e6/grpcio_status-1.75.1-py3-none-any.whl
- name: grpcio-status
- version: 1.75.1
- sha256: f681b301be26dcf7abf5c765d4a22e4098765e1a65cbdfa3efca384edf8e4e3c
- requires_dist:
- - protobuf>=6.31.1,<7.0.0
- - grpcio>=1.75.1
- - googleapis-common-protos>=1.5.5
- requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl
name: h11
version: 0.16.0
sha256: 63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/15/07/86397573efefff941e100367bbda0b21496ffcdb34db7ab51912994c32a2/hf_xet-1.1.10-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/3c/4c/781267da3188db679e601de18112021a5cb16506fe86b246e22c5401a9c4/hf_xet-1.4.2-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
name: hf-xet
- version: 1.1.10
- sha256: 6b6bceb6361c80c1cc42b5a7b4e3efd90e64630bcf11224dcac50ef30a47e435
- requires_dist:
- - pytest ; extra == 'tests'
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/9a/92/cf3ab0b652b082e66876d08da57fcc6fa2f0e6c70dfbbafbd470bb73eb47/hf_xet-1.2.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- name: hf-xet
- version: 1.2.0
- sha256: 3651fd5bfe0281951b988c0facbe726aa5e347b103a675f49a3fa8144c7968fd
+ version: 1.4.2
+ sha256: 77e8c180b7ef12d8a96739a4e1e558847002afe9ea63b6f6358b2271a8bdda1c
requires_dist:
- pytest ; extra == 'tests'
requires_python: '>=3.8'
@@ -1695,13 +1599,6 @@ packages:
- socksio==1.* ; extra == 'socks'
- trio>=0.22.0,<1.0 ; extra == 'trio'
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/8c/a2/0d269db0f6163be503775dc8b6a6fa15820cc9fdc866f6ba608d86b721f2/httplib2-0.31.0-py3-none-any.whl
- name: httplib2
- version: 0.31.0
- sha256: b9cd78abea9b4e43a7714c6e0f8b6b8561a6fc1e95d5dbd367f5bf0ef35f5d24
- requires_dist:
- - pyparsing>=3.0.4,<4
- requires_python: '>=3.6'
- pypi: https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl
name: httpx
version: 0.28.1
@@ -1720,109 +1617,40 @@ packages:
- socksio==1.* ; extra == 'socks'
- zstandard>=0.18.0 ; extra == 'zstd'
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/31/a0/651f93d154cb72323358bf2bbae3e642bdb5d2f1bfc874d096f7cb159fa0/huggingface_hub-0.35.3-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl
name: huggingface-hub
- version: 0.35.3
- sha256: 0e3a01829c19d86d03793e4577816fe3bdfc1602ac62c7fb220d593d351224ba
+ version: 0.36.2
+ sha256: 48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270
requires_dist:
- filelock
- fsspec>=2023.5.0
+ - hf-xet>=1.1.3,<2.0.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
- packaging>=20.9
- pyyaml>=5.1
- requests
- tqdm>=4.42.1
- typing-extensions>=3.7.4.3
- - hf-xet>=1.1.3,<2.0.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
- - inquirerpy==0.3.4 ; extra == 'all'
- - aiohttp ; extra == 'all'
- - authlib>=1.3.2 ; extra == 'all'
- - fastapi ; extra == 'all'
- - httpx ; extra == 'all'
- - itsdangerous ; extra == 'all'
- - jedi ; extra == 'all'
- - jinja2 ; extra == 'all'
- - pytest>=8.1.1,<8.2.2 ; extra == 'all'
- - pytest-cov ; extra == 'all'
- - pytest-env ; extra == 'all'
- - pytest-xdist ; extra == 'all'
- - pytest-vcr ; extra == 'all'
- - pytest-asyncio ; extra == 'all'
- - pytest-rerunfailures<16.0 ; extra == 'all'
- - pytest-mock ; extra == 'all'
- - urllib3<2.0 ; extra == 'all'
- - soundfile ; extra == 'all'
- - pillow ; extra == 'all'
- - gradio>=4.0.0 ; extra == 'all'
- - numpy ; extra == 'all'
- - ruff>=0.9.0 ; extra == 'all'
- - libcst>=1.4.0 ; extra == 'all'
- - ty ; extra == 'all'
- - typing-extensions>=4.8.0 ; extra == 'all'
- - types-pyyaml ; extra == 'all'
- - types-requests ; extra == 'all'
- - types-simplejson ; extra == 'all'
- - types-toml ; extra == 'all'
- - types-tqdm ; extra == 'all'
- - types-urllib3 ; extra == 'all'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'all'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'all'
- inquirerpy==0.3.4 ; extra == 'cli'
- - inquirerpy==0.3.4 ; extra == 'dev'
- - aiohttp ; extra == 'dev'
- - authlib>=1.3.2 ; extra == 'dev'
- - fastapi ; extra == 'dev'
- - httpx ; extra == 'dev'
- - itsdangerous ; extra == 'dev'
- - jedi ; extra == 'dev'
- - jinja2 ; extra == 'dev'
- - pytest>=8.1.1,<8.2.2 ; extra == 'dev'
- - pytest-cov ; extra == 'dev'
- - pytest-env ; extra == 'dev'
- - pytest-xdist ; extra == 'dev'
- - pytest-vcr ; extra == 'dev'
- - pytest-asyncio ; extra == 'dev'
- - pytest-rerunfailures<16.0 ; extra == 'dev'
- - pytest-mock ; extra == 'dev'
- - urllib3<2.0 ; extra == 'dev'
- - soundfile ; extra == 'dev'
- - pillow ; extra == 'dev'
- - gradio>=4.0.0 ; extra == 'dev'
- - numpy ; extra == 'dev'
- - ruff>=0.9.0 ; extra == 'dev'
- - libcst>=1.4.0 ; extra == 'dev'
- - ty ; extra == 'dev'
- - typing-extensions>=4.8.0 ; extra == 'dev'
- - types-pyyaml ; extra == 'dev'
- - types-requests ; extra == 'dev'
- - types-simplejson ; extra == 'dev'
- - types-toml ; extra == 'dev'
- - types-tqdm ; extra == 'dev'
- - types-urllib3 ; extra == 'dev'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'dev'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'dev'
- - toml ; extra == 'fastai'
- - fastai>=2.4 ; extra == 'fastai'
- - fastcore>=1.3.27 ; extra == 'fastai'
- - hf-transfer>=0.1.4 ; extra == 'hf-transfer'
- - hf-xet>=1.1.2,<2.0.0 ; extra == 'hf-xet'
- aiohttp ; extra == 'inference'
- - mcp>=1.8.0 ; extra == 'mcp'
- - typer ; extra == 'mcp'
- - aiohttp ; extra == 'mcp'
- authlib>=1.3.2 ; extra == 'oauth'
- fastapi ; extra == 'oauth'
- httpx ; extra == 'oauth'
- itsdangerous ; extra == 'oauth'
- - ruff>=0.9.0 ; extra == 'quality'
- - libcst>=1.4.0 ; extra == 'quality'
- - ty ; extra == 'quality'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'quality'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'quality'
+ - torch ; extra == 'torch'
+ - safetensors[torch] ; extra == 'torch'
+ - hf-transfer>=0.1.4 ; extra == 'hf-transfer'
+ - toml ; extra == 'fastai'
+ - fastai>=2.4 ; extra == 'fastai'
+ - fastcore>=1.3.27 ; extra == 'fastai'
- tensorflow ; extra == 'tensorflow'
- pydot ; extra == 'tensorflow'
- graphviz ; extra == 'tensorflow'
- tensorflow ; extra == 'tensorflow-testing'
- keras<3.0 ; extra == 'tensorflow-testing'
+ - hf-xet>=1.1.2,<2.0.0 ; extra == 'hf-xet'
+ - mcp>=1.8.0 ; extra == 'mcp'
+ - typer ; extra == 'mcp'
+ - aiohttp ; extra == 'mcp'
- inquirerpy==0.3.4 ; extra == 'testing'
- aiohttp ; extra == 'testing'
- authlib>=1.3.2 ; extra == 'testing'
@@ -1844,8 +1672,7 @@ packages:
- pillow ; extra == 'testing'
- gradio>=4.0.0 ; extra == 'testing'
- numpy ; extra == 'testing'
- - torch ; extra == 'torch'
- - safetensors[torch] ; extra == 'torch'
+ - fastapi ; extra == 'testing'
- typing-extensions>=4.8.0 ; extra == 'typing'
- types-pyyaml ; extra == 'typing'
- types-requests ; extra == 'typing'
@@ -1853,20 +1680,11 @@ packages:
- types-toml ; extra == 'typing'
- types-tqdm ; extra == 'typing'
- types-urllib3 ; extra == 'typing'
- requires_python: '>=3.8.0'
-- pypi: https://files.pythonhosted.org/packages/cb/bd/1a875e0d592d447cbc02805fd3fe0f497714d6a2583f59d14fa9ebad96eb/huggingface_hub-0.36.0-py3-none-any.whl
- name: huggingface-hub
- version: 0.36.0
- sha256: 7bcc9ad17d5b3f07b57c78e79d527102d08313caa278a641993acddcb894548d
- requires_dist:
- - filelock
- - fsspec>=2023.5.0
- - packaging>=20.9
- - pyyaml>=5.1
- - requests
- - tqdm>=4.42.1
- - typing-extensions>=3.7.4.3
- - hf-xet>=1.1.3,<2.0.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
+ - ruff>=0.9.0 ; extra == 'quality'
+ - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'quality'
+ - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'quality'
+ - libcst>=1.4.0 ; extra == 'quality'
+ - ty ; extra == 'quality'
- inquirerpy==0.3.4 ; extra == 'all'
- aiohttp ; extra == 'all'
- authlib>=1.3.2 ; extra == 'all'
@@ -1888,7 +1706,10 @@ packages:
- pillow ; extra == 'all'
- gradio>=4.0.0 ; extra == 'all'
- numpy ; extra == 'all'
+ - fastapi ; extra == 'all'
- ruff>=0.9.0 ; extra == 'all'
+ - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'all'
+ - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'all'
- libcst>=1.4.0 ; extra == 'all'
- ty ; extra == 'all'
- typing-extensions>=4.8.0 ; extra == 'all'
@@ -1898,9 +1719,6 @@ packages:
- types-toml ; extra == 'all'
- types-tqdm ; extra == 'all'
- types-urllib3 ; extra == 'all'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'all'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'all'
- - inquirerpy==0.3.4 ; extra == 'cli'
- inquirerpy==0.3.4 ; extra == 'dev'
- aiohttp ; extra == 'dev'
- authlib>=1.3.2 ; extra == 'dev'
@@ -1922,7 +1740,10 @@ packages:
- pillow ; extra == 'dev'
- gradio>=4.0.0 ; extra == 'dev'
- numpy ; extra == 'dev'
+ - fastapi ; extra == 'dev'
- ruff>=0.9.0 ; extra == 'dev'
+ - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'dev'
+ - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'dev'
- libcst>=1.4.0 ; extra == 'dev'
- ty ; extra == 'dev'
- typing-extensions>=4.8.0 ; extra == 'dev'
@@ -1932,78 +1753,44 @@ packages:
- types-toml ; extra == 'dev'
- types-tqdm ; extra == 'dev'
- types-urllib3 ; extra == 'dev'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'dev'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'dev'
- - toml ; extra == 'fastai'
- - fastai>=2.4 ; extra == 'fastai'
- - fastcore>=1.3.27 ; extra == 'fastai'
- - hf-transfer>=0.1.4 ; extra == 'hf-transfer'
- - hf-xet>=1.1.2,<2.0.0 ; extra == 'hf-xet'
- - aiohttp ; extra == 'inference'
- - mcp>=1.8.0 ; extra == 'mcp'
- - typer ; extra == 'mcp'
- - aiohttp ; extra == 'mcp'
- - authlib>=1.3.2 ; extra == 'oauth'
- - fastapi ; extra == 'oauth'
- - httpx ; extra == 'oauth'
- - itsdangerous ; extra == 'oauth'
- - ruff>=0.9.0 ; extra == 'quality'
- - libcst>=1.4.0 ; extra == 'quality'
- - ty ; extra == 'quality'
- - mypy>=1.14.1,<1.15.0 ; python_full_version == '3.8.*' and extra == 'quality'
- - mypy==1.15.0 ; python_full_version >= '3.9' and extra == 'quality'
- - tensorflow ; extra == 'tensorflow'
- - pydot ; extra == 'tensorflow'
- - graphviz ; extra == 'tensorflow'
- - tensorflow ; extra == 'tensorflow-testing'
- - keras<3.0 ; extra == 'tensorflow-testing'
- - inquirerpy==0.3.4 ; extra == 'testing'
- - aiohttp ; extra == 'testing'
- - authlib>=1.3.2 ; extra == 'testing'
- - fastapi ; extra == 'testing'
- - httpx ; extra == 'testing'
- - itsdangerous ; extra == 'testing'
- - jedi ; extra == 'testing'
- - jinja2 ; extra == 'testing'
- - pytest>=8.1.1,<8.2.2 ; extra == 'testing'
- - pytest-cov ; extra == 'testing'
- - pytest-env ; extra == 'testing'
- - pytest-xdist ; extra == 'testing'
- - pytest-vcr ; extra == 'testing'
- - pytest-asyncio ; extra == 'testing'
- - pytest-rerunfailures<16.0 ; extra == 'testing'
- - pytest-mock ; extra == 'testing'
- - urllib3<2.0 ; extra == 'testing'
- - soundfile ; extra == 'testing'
- - pillow ; extra == 'testing'
- - gradio>=4.0.0 ; extra == 'testing'
- - numpy ; extra == 'testing'
- - torch ; extra == 'torch'
- - safetensors[torch] ; extra == 'torch'
- - typing-extensions>=4.8.0 ; extra == 'typing'
- - types-pyyaml ; extra == 'typing'
- - types-requests ; extra == 'typing'
- - types-simplejson ; extra == 'typing'
- - types-toml ; extra == 'typing'
- - types-tqdm ; extra == 'typing'
- - types-urllib3 ; extra == 'typing'
requires_python: '>=3.8.0'
-- pypi: https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl
- name: humanfriendly
- version: '10.0'
- sha256: 1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477
+- conda: https://conda.anaconda.org/conda-forge/linux-64/icu-78.3-h33c6efd_0.conda
+ sha256: fbf86c4a59c2ed05bbffb2ba25c7ed94f6185ec30ecb691615d42342baa1a16a
+ md5: c80d8a3b84358cb967fa81e7075fbc8a
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
+ - libstdcxx >=14
+ license: MIT
+ license_family: MIT
+ purls: []
+ size: 12723451
+ timestamp: 1773822285671
+- pypi: https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl
+ name: id
+ version: 1.6.1
+ sha256: f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca
requires_dist:
- - monotonic ; python_full_version == '2.7.*'
- - pyreadline ; python_full_version < '3.8' and sys_platform == 'win32'
- - pyreadline3 ; python_full_version >= '3.8' and sys_platform == 'win32'
- requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*'
-- pypi: https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl
+ - urllib3>=2,<3
+ - build ; extra == 'dev'
+ - bump>=1.3.2 ; extra == 'dev'
+ - id[test,lint] ; extra == 'dev'
+ - bandit ; extra == 'lint'
+ - interrogate ; extra == 'lint'
+ - mypy ; extra == 'lint'
+ - ruff<0.14.15 ; extra == 'lint'
+ - pytest ; extra == 'test'
+ - pytest-cov ; extra == 'test'
+ - pretend ; extra == 'test'
+ - coverage[toml] ; extra == 'test'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl
name: identify
- version: 2.6.15
- sha256: 1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757
+ version: 2.6.18
+ sha256: 8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737
requires_dist:
- ukkonen ; extra == 'license'
- requires_python: '>=3.9'
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl
name: idna
version: '3.11'
@@ -2014,79 +1801,18 @@ packages:
- pytest>=8.3.2 ; extra == 'all'
- flake8>=7.1.1 ; extra == 'all'
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/cb/bd/b394387b598ed84d8d0fa90611a90bee0adc2021820ad5729f7ced74a8e2/imageio-2.37.0-py3-none-any.whl
- name: imageio
- version: 2.37.0
- sha256: 11efa15b87bc7871b61590326b2d635439acc321cf7f8ce996f812543ce10eed
- requires_dist:
- - numpy
- - pillow>=8.3.2
- - astropy ; extra == 'all-plugins'
- - av ; extra == 'all-plugins'
- - imageio-ffmpeg ; extra == 'all-plugins'
- - numpy>2 ; extra == 'all-plugins'
- - pillow-heif ; extra == 'all-plugins'
- - psutil ; extra == 'all-plugins'
- - rawpy ; extra == 'all-plugins'
- - tifffile ; extra == 'all-plugins'
- - av ; extra == 'all-plugins-pypy'
- - imageio-ffmpeg ; extra == 'all-plugins-pypy'
- - pillow-heif ; extra == 'all-plugins-pypy'
- - psutil ; extra == 'all-plugins-pypy'
- - tifffile ; extra == 'all-plugins-pypy'
- - wheel ; extra == 'build'
- - pytest ; extra == 'dev'
- - pytest-cov ; extra == 'dev'
- - fsspec[github] ; extra == 'dev'
- - black ; extra == 'dev'
- - flake8 ; extra == 'dev'
- - sphinx<6 ; extra == 'docs'
- - numpydoc ; extra == 'docs'
- - pydata-sphinx-theme ; extra == 'docs'
- - imageio-ffmpeg ; extra == 'ffmpeg'
- - psutil ; extra == 'ffmpeg'
- - astropy ; extra == 'fits'
- - astropy ; extra == 'full'
- - av ; extra == 'full'
- - black ; extra == 'full'
- - flake8 ; extra == 'full'
- - fsspec[github] ; extra == 'full'
- - gdal ; extra == 'full'
- - imageio-ffmpeg ; extra == 'full'
- - itk ; extra == 'full'
- - numpy>2 ; extra == 'full'
- - numpydoc ; extra == 'full'
- - pillow-heif ; extra == 'full'
- - psutil ; extra == 'full'
- - pydata-sphinx-theme ; extra == 'full'
- - pytest ; extra == 'full'
- - pytest-cov ; extra == 'full'
- - rawpy ; extra == 'full'
- - sphinx<6 ; extra == 'full'
- - tifffile ; extra == 'full'
- - wheel ; extra == 'full'
- - gdal ; extra == 'gdal'
- - itk ; extra == 'itk'
- - black ; extra == 'linting'
- - flake8 ; extra == 'linting'
- - pillow-heif ; extra == 'pillow-heif'
- - av ; extra == 'pyav'
- - rawpy ; extra == 'rawpy'
- - numpy>2 ; extra == 'rawpy'
- - pytest ; extra == 'test'
- - pytest-cov ; extra == 'test'
- - fsspec[github] ; extra == 'test'
- - tifffile ; extra == 'tifffile'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/5f/53/fb7122b71361a0d121b669dcf3d31244ef75badbbb724af388948de543e2/imagesize-2.0.0-py2.py3-none-any.whl
+ name: imagesize
+ version: 2.0.0
+ sha256: 5667c5bbb57ab3f1fa4bc366f4fbc971db3d5ed011fd2715fd8001f782718d96
+ requires_python: '>=3.10,<3.15'
+- pypi: https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl
name: importlib-metadata
- version: 8.7.0
- sha256: e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd
+ version: 8.7.1
+ sha256: 5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151
requires_dist:
- zipp>=3.20
- - typing-extensions>=3.6.4 ; python_full_version < '3.8'
- pytest>=6,!=8.1.* ; extra == 'test'
- - importlib-resources>=1.3 ; python_full_version < '3.9' and extra == 'test'
- packaging ; extra == 'test'
- pyfakefs ; extra == 'test'
- flufl-flake8 ; extra == 'test'
@@ -2102,14 +1828,15 @@ packages:
- pytest-checkdocs>=2.4 ; extra == 'check'
- pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check'
- pytest-cov ; extra == 'cover'
- - pytest-enabler>=2.2 ; extra == 'enabler'
- - pytest-mypy ; extra == 'type'
+ - pytest-enabler>=3.4 ; extra == 'enabler'
+ - pytest-mypy>=1.0.1 ; extra == 'type'
+ - mypy<1.19 ; platform_python_implementation == 'PyPy' and extra == 'type'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl
name: iniconfig
- version: 2.1.0
- sha256: 9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760
- requires_python: '>=3.8'
+ version: 2.3.0
+ sha256: f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/32/4b/b99e37f88336009971405cbb7630610322ed6fbfa31e1d7ab3fbf3049a2d/invoke-2.2.1-py3-none-any.whl
name: invoke
version: 2.2.1
@@ -2120,6 +1847,80 @@ packages:
version: 0.7.2
sha256: 28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15
requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl
+ name: jaraco-classes
+ version: 3.4.0
+ sha256: f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790
+ requires_dist:
+ - more-itertools
+ - sphinx>=3.5 ; extra == 'docs'
+ - jaraco-packaging>=9.3 ; extra == 'docs'
+ - rst-linker>=1.9 ; extra == 'docs'
+ - furo ; extra == 'docs'
+ - sphinx-lint ; extra == 'docs'
+ - jaraco-tidelift>=1.4 ; extra == 'docs'
+ - pytest>=6 ; extra == 'testing'
+ - pytest-checkdocs>=2.4 ; extra == 'testing'
+ - pytest-cov ; extra == 'testing'
+ - pytest-mypy ; extra == 'testing'
+ - pytest-enabler>=2.2 ; extra == 'testing'
+ - pytest-ruff>=0.2.1 ; extra == 'testing'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl
+ name: jaraco-context
+ version: 6.1.2
+ sha256: bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535
+ requires_dist:
+ - backports-tarfile ; python_full_version < '3.12'
+ - pytest>=6,!=8.1.* ; extra == 'test'
+ - jaraco-test>=5.6.0 ; extra == 'test'
+ - portend ; extra == 'test'
+ - sphinx>=3.5 ; extra == 'doc'
+ - jaraco-packaging>=9.3 ; extra == 'doc'
+ - rst-linker>=1.9 ; extra == 'doc'
+ - furo ; extra == 'doc'
+ - sphinx-lint ; extra == 'doc'
+ - jaraco-tidelift>=1.4 ; extra == 'doc'
+ - pytest-checkdocs>=2.14 ; extra == 'check'
+ - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check'
+ - pytest-cov ; extra == 'cover'
+ - pytest-enabler>=3.4 ; extra == 'enabler'
+ - pytest-mypy>=1.0.1 ; platform_python_implementation != 'PyPy' and extra == 'type'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl
+ name: jaraco-functools
+ version: 4.4.0
+ sha256: 9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176
+ requires_dist:
+ - more-itertools
+ - pytest>=6,!=8.1.* ; extra == 'test'
+ - jaraco-classes ; extra == 'test'
+ - sphinx>=3.5 ; extra == 'doc'
+ - jaraco-packaging>=9.3 ; extra == 'doc'
+ - rst-linker>=1.9 ; extra == 'doc'
+ - furo ; extra == 'doc'
+ - sphinx-lint ; extra == 'doc'
+ - jaraco-tidelift>=1.4 ; extra == 'doc'
+ - pytest-checkdocs>=2.4 ; extra == 'check'
+ - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check'
+ - pytest-cov ; extra == 'cover'
+ - pytest-enabler>=3.4 ; extra == 'enabler'
+ - pytest-mypy>=1.0.1 ; extra == 'type'
+ - mypy<1.19 ; platform_python_implementation == 'PyPy' and extra == 'type'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl
+ name: jeepney
+ version: 0.9.0
+ sha256: 97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683
+ requires_dist:
+ - pytest ; extra == 'test'
+ - pytest-trio ; extra == 'test'
+ - pytest-asyncio>=0.17 ; extra == 'test'
+ - testpath ; extra == 'test'
+ - trio ; extra == 'test'
+ - async-timeout ; python_full_version < '3.11' and extra == 'test'
+ - trio ; extra == 'trio'
+ requires_python: '>=3.7'
- pypi: https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl
name: jinja2
version: 3.1.6
@@ -2128,25 +1929,25 @@ packages:
- markupsafe>=2.0
- babel>=2.7 ; extra == 'i18n'
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/9a/5f/0dc34563d8164d31d07bc09d141d3da08157a68dcd1f9b886fa4e917805b/jiter-0.11.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: jiter
- version: 0.11.0
- sha256: cf408d2a0abd919b60de8c2e7bc5eeab72d4dafd18784152acc7c9adc3291591
+ version: 0.13.0
+ sha256: bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/1e/e8/685f47e0d754320684db4425a0967f7d3fa70126bffd76110b7009a0090f/joblib-1.5.2-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl
name: joblib
- version: 1.5.2
- sha256: 4e1f0bdbb987e6d843c70cf43714cb276623def372df3c22fe5266b2670bc241
+ version: 1.5.3
+ sha256: 5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl
name: jsonschema
- version: 4.25.1
- sha256: 3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63
+ version: 4.26.0
+ sha256: d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce
requires_dist:
- attrs>=22.2.0
- jsonschema-specifications>=2023.3.6
- referencing>=0.28.4
- - rpds-py>=0.7.1
+ - rpds-py>=0.25.0
- fqdn ; extra == 'format'
- idna ; extra == 'format'
- isoduration ; extra == 'format'
@@ -2164,7 +1965,7 @@ packages:
- rfc3987-syntax>=1.1.0 ; extra == 'format-nongpl'
- uri-template ; extra == 'format-nongpl'
- webcolors>=24.6.0 ; extra == 'format-nongpl'
- requires_python: '>=3.9'
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl
name: jsonschema-specifications
version: 2025.9.1
@@ -2172,55 +1973,72 @@ packages:
requires_dist:
- referencing>=0.31.0
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/83/60/d497a310bde3f01cb805196ac61b7ad6dc5dcf8dce66634dc34364b20b4f/lazy_loader-0.4-py3-none-any.whl
- name: lazy-loader
- version: '0.4'
- sha256: 342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc
+- pypi: https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl
+ name: keyring
+ version: 25.7.0
+ sha256: be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f
requires_dist:
- - packaging
- - importlib-metadata ; python_full_version < '3.8'
- - changelist==0.5 ; extra == 'dev'
- - pre-commit==3.7.0 ; extra == 'lint'
- - pytest>=7.4 ; extra == 'test'
- - pytest-cov>=4.1 ; extra == 'test'
- requires_python: '>=3.7'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-h1aa0949_4.conda
- sha256: 96b6900ca0489d9e5d0318a6b49f8eff43fd85fef6e07cb0c25344ee94cd7a3a
- md5: c94ab6ff54ba5172cf1c58267005670f
+ - pywin32-ctypes>=0.2.0 ; sys_platform == 'win32'
+ - secretstorage>=3.2 ; sys_platform == 'linux'
+ - jeepney>=0.4.2 ; sys_platform == 'linux'
+ - importlib-metadata>=4.11.4 ; python_full_version < '3.12'
+ - jaraco-classes
+ - jaraco-functools
+ - jaraco-context
+ - pytest>=6,!=8.1.* ; extra == 'test'
+ - pyfakefs ; extra == 'test'
+ - sphinx>=3.5 ; extra == 'doc'
+ - jaraco-packaging>=9.3 ; extra == 'doc'
+ - rst-linker>=1.9 ; extra == 'doc'
+ - furo ; extra == 'doc'
+ - sphinx-lint ; extra == 'doc'
+ - jaraco-tidelift>=1.4 ; extra == 'doc'
+ - pytest-checkdocs>=2.4 ; extra == 'check'
+ - pytest-ruff>=0.2.1 ; sys_platform != 'cygwin' and extra == 'check'
+ - pytest-cov ; extra == 'cover'
+ - pytest-enabler>=3.4 ; extra == 'enabler'
+ - pytest-mypy>=1.0.1 ; extra == 'type'
+ - pygobject-stubs ; extra == 'type'
+ - shtab ; extra == 'type'
+ - types-pywin32 ; extra == 'type'
+ - shtab>=1.1.0 ; extra == 'completion'
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.45.1-default_hbd61a6d_101.conda
+ sha256: 565941ac1f8b0d2f2e8f02827cbca648f4d18cd461afc31f15604cd291b5c5f3
+ md5: 12bd9a3f089ee6c9266a37dab82afabd
depends:
- __glibc >=2.17,<3.0.a0
- zstd >=1.5.7,<1.6.0a0
constrains:
- - binutils_impl_linux-64 2.44
+ - binutils_impl_linux-64 2.45.1
license: GPL-3.0-only
license_family: GPL
purls: []
- size: 742501
- timestamp: 1761335175964
-- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.44-ha97dd6f_2.conda
- sha256: 707dfb8d55d7a5c6f95c772d778ef07a7ca85417d9971796f7d3daad0b615de8
- md5: 14bae321b8127b63cba276bd53fac237
+ size: 725507
+ timestamp: 1770267139900
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libattr-2.5.2-hb03c661_1.conda
+ sha256: 0cef37eb013dc7091f17161c357afbdef9a9bc79ef6462508face6db3f37db77
+ md5: 7e7f0a692eb62b95d3010563e7f963b6
depends:
- __glibc >=2.17,<3.0.a0
- constrains:
- - binutils_impl_linux-64 2.44
- license: GPL-3.0-only
- license_family: GPL
+ - libgcc >=14
+ license: LGPL-2.1-or-later
+ license_family: LGPL
purls: []
- size: 747158
- timestamp: 1758810907507
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.76-h0b2e76d_0.conda
- sha256: a946b61be1af15ff08c7722e9bac0fab446d8b9896c9f0f35657dfcf887fda8a
- md5: 0f7f0c878c8dceb3b9ec67f5c06d6057
+ size: 53316
+ timestamp: 1773595896163
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libcap-2.77-h3ff7636_0.conda
+ sha256: 9517cce5193144af0fcbf19b7bd67db0a329c2cc2618f28ffecaa921a1cbe9d3
+ md5: 09c264d40c67b82b49a3f3b89037bd2e
depends:
- __glibc >=2.17,<3.0.a0
- - attr >=2.5.1,<2.6.0a0
- - libgcc >=13
+ - attr >=2.5.2,<2.6.0a0
+ - libgcc >=14
license: BSD-3-Clause
license_family: BSD
purls: []
- size: 121852
- timestamp: 1744577167992
+ size: 121429
+ timestamp: 1762349484074
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcublas-12.9.1.4-h676940d_1.conda
sha256: 671a5204ae983c775d17b3f55b2b0f8ee8cb73b8f0c8b6036070dfadc2770707
md5: af0df9bc982b5ed2c67e8f5062d1f8c1
@@ -2234,9 +2052,9 @@ packages:
purls: []
size: 467746725
timestamp: 1761086109565
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-9.13.1.26-hf7e9902_0.conda
- sha256: 490d4d2136dda223b71e391363810aa9bb542d0d4a22270901df344090d0e394
- md5: 9cd33afe990aff3302820edb37fad8d4
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-9.10.2.21-hf7e9902_0.conda
+ sha256: dc6b89e874867b2cdf08224059bd1543cbb72ed646da177c1454596469c9a4bb
+ md5: a178a1f3642521f104ecceeefa138d01
depends:
- __glibc >=2.28,<3.0.a0
- cuda-nvrtc
@@ -2249,23 +2067,23 @@ packages:
- libcudnn-jit <0a
license: LicenseRef-cuDNN-Software-License-Agreement
purls: []
- size: 447009909
- timestamp: 1759247115298
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-dev-9.13.1.26-h58dd1b1_0.conda
- sha256: af7c9b773738dfbb1d2d0354c9459a95a0e77e53380532a8486139764294a171
- md5: f048ce39203cfab52eba53f35c3228c0
+ size: 526823453
+ timestamp: 1762823414388
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libcudnn-dev-9.10.2.21-h58dd1b1_0.conda
+ sha256: e9fef18b943a8181427734bc9fada8a594e3a8391fa2a8d59d980acfe1c2cf04
+ md5: 7d7a47d067261531c3089dcec326d6fa
depends:
- __glibc >=2.28,<3.0.a0
- cuda-version >=12,<13.0a0
- - libcudnn 9.13.1.26 hf7e9902_0
+ - libcudnn 9.10.2.21 hf7e9902_0
- libgcc >=14
- libstdcxx >=14
constrains:
- libcudnn-jit-dev <0a
license: LicenseRef-cuDNN-Software-License-Agreement
purls: []
- size: 44048
- timestamp: 1759247497317
+ size: 44188
+ timestamp: 1762823889020
- conda: https://conda.anaconda.org/conda-forge/linux-64/libcufft-11.4.1.4-hecca717_1.conda
sha256: 62d4214c182c89cfb02271a42eaac56a41f50bbbea3b0d795a8e33f167a39a4e
md5: 75ae571353ec92c8f34d4cf6ec6ba264
@@ -2331,110 +2149,76 @@ packages:
purls: []
size: 208846028
timestamp: 1761069913328
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.1-hecca717_0.conda
- sha256: da2080da8f0288b95dd86765c801c6e166c4619b910b11f9a8446fb852438dc2
- md5: 4211416ecba1866fab0c6470986c22d6
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.7.4-hecca717_0.conda
+ sha256: d78f1d3bea8c031d2f032b760f36676d87929b18146351c4464c66b0869df3f5
+ md5: e7f7ce06ec24cfcfb9e36d28cf82ba57
depends:
- __glibc >=2.17,<3.0.a0
- libgcc >=14
constrains:
- - expat 2.7.1.*
+ - expat 2.7.4.*
license: MIT
license_family: MIT
purls: []
- size: 74811
- timestamp: 1752719572741
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.4.6-h2dba641_1.conda
- sha256: 764432d32db45466e87f10621db5b74363a9f847d2b8b1f9743746cd160f06ab
- md5: ede4673863426c0883c0063d853bbd85
- depends:
- - __glibc >=2.17,<3.0.a0
- - libgcc >=13
- license: MIT
- license_family: MIT
- purls: []
- size: 57433
- timestamp: 1743434498161
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h9ec8514_0.conda
- sha256: 25cbdfa65580cfab1b8d15ee90b4c9f1e0d72128f1661449c9a999d341377d54
- md5: 35f29eec58405aaf55e01cb470d8c26a
+ size: 76798
+ timestamp: 1771259418166
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.5.2-h3435931_0.conda
+ sha256: 31f19b6a88ce40ebc0d5a992c131f57d919f73c0b92cd1617a5bec83f6e961e6
+ md5: a360c33a5abe61c07959e449fa1453eb
depends:
- __glibc >=2.17,<3.0.a0
- libgcc >=14
license: MIT
license_family: MIT
purls: []
- size: 57821
- timestamp: 1760295480630
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-h767d61c_7.conda
- sha256: 08f9b87578ab981c7713e4e6a7d935e40766e10691732bba376d4964562bcb45
- md5: c0374badb3a5d4b1372db28d19462c53
+ size: 58592
+ timestamp: 1769456073053
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-15.2.0-he0feb66_18.conda
+ sha256: faf7d2017b4d718951e3a59d081eb09759152f93038479b768e3d612688f83f5
+ md5: 0aa00f03f9e39fb9876085dee11a85d4
depends:
- __glibc >=2.17,<3.0.a0
- _openmp_mutex >=4.5
constrains:
- - libgomp 15.2.0 h767d61c_7
- - libgcc-ng ==15.2.0=*_7
+ - libgcc-ng ==15.2.0=*_18
+ - libgomp 15.2.0 he0feb66_18
license: GPL-3.0-only WITH GCC-exception-3.1
license_family: GPL
purls: []
- size: 822552
- timestamp: 1759968052178
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_7.conda
- sha256: 2045066dd8e6e58aaf5ae2b722fb6dfdbb57c862b5f34ac7bfb58c40ef39b6ad
- md5: 280ea6eee9e2ddefde25ff799c4f0363
+ size: 1041788
+ timestamp: 1771378212382
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-ng-15.2.0-h69a702a_18.conda
+ sha256: e318a711400f536c81123e753d4c797a821021fb38970cebfb3f454126016893
+ md5: d5e96b1ed75ca01906b3d2469b4ce493
depends:
- - libgcc 15.2.0 h767d61c_7
+ - libgcc 15.2.0 he0feb66_18
license: GPL-3.0-only WITH GCC-exception-3.1
license_family: GPL
purls: []
- size: 29313
- timestamp: 1759968065504
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcrypt-lib-1.11.1-hb9d3cd8_0.conda
- sha256: dc9c7d7a6c0e6639deee6fde2efdc7e119e7739a6b229fa5f9049a449bae6109
- md5: 8504a291085c9fb809b66cabd5834307
- depends:
- - __glibc >=2.17,<3.0.a0
- - libgcc >=13
- - libgpg-error >=1.55,<2.0a0
- license: LGPL-2.1-or-later
- purls: []
- size: 590353
- timestamp: 1747060639058
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-h767d61c_7.conda
- sha256: e9fb1c258c8e66ee278397b5822692527c5f5786d372fe7a869b900853f3f5ca
- md5: f7b4d76975aac7e5d9e6ad13845f92fe
+ size: 27526
+ timestamp: 1771378224552
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-15.2.0-he0feb66_18.conda
+ sha256: 21337ab58e5e0649d869ab168d4e609b033509de22521de1bfed0c031bfc5110
+ md5: 239c5e9546c38a1e884d69effcf4c882
depends:
- __glibc >=2.17,<3.0.a0
license: GPL-3.0-only WITH GCC-exception-3.1
license_family: GPL
purls: []
- size: 447919
- timestamp: 1759967942498
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libgpg-error-1.55-h3f2d84a_0.conda
- sha256: 697334de4786a1067ea86853e520c64dd72b11a05137f5b318d8a444007b5e60
- md5: 2bd47db5807daade8500ed7ca4c512a4
+ size: 603262
+ timestamp: 1771378117851
+- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.2-hb03c661_0.conda
+ sha256: 755c55ebab181d678c12e49cced893598f2bab22d582fbbf4d8b83c18be207eb
+ md5: c7c83eecbb72d88b940c249af56c8b17
depends:
- - libstdcxx >=13
- - libgcc >=13
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
- license: LGPL-2.1-only
- purls: []
- size: 312184
- timestamp: 1745575272035
-- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.1-hb9d3cd8_2.conda
- sha256: f2591c0069447bbe28d4d696b7fcb0c5bd0b4ac582769b89addbcf26fb3430d8
- md5: 1a580f7796c7bf6393fddb8bbbde58dc
- depends:
- - __glibc >=2.17,<3.0.a0
- - libgcc >=13
+ - libgcc >=14
constrains:
- - xz 5.8.1.*
+ - xz 5.8.2.*
license: 0BSD
purls: []
- size: 112894
- timestamp: 1749230047870
+ size: 113207
+ timestamp: 1768752626120
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnl-3.11.0-hb9d3cd8_0.conda
sha256: ba7c5d294e3d80f08ac5a39564217702d1a752e352e486210faff794ac5001b4
md5: db63358239cbe1ff86242406d440e44a
@@ -2505,67 +2289,64 @@ packages:
purls: []
size: 3582778
timestamp: 1761098854056
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.50.4-h0c1763c_0.conda
- sha256: 6d9c32fc369af5a84875725f7ddfbfc2ace795c28f246dc70055a79f9b2003da
- md5: 0b367fad34931cb79e0d6b7e5c06bb1c
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.52.0-hf4e2dac_0.conda
+ sha256: d716847b7deca293d2e49ed1c8ab9e4b9e04b9d780aea49a97c26925b28a7993
+ md5: fd893f6a3002a635b5e50ceb9dd2c0f4
depends:
- __glibc >=2.17,<3.0.a0
+ - icu >=78.2,<79.0a0
- libgcc >=14
- libzlib >=1.3.1,<2.0a0
license: blessing
purls: []
- size: 932581
- timestamp: 1753948484112
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h8f9b012_7.conda
- sha256: 1b981647d9775e1cdeb2fab0a4dd9cd75a6b0de2963f6c3953dbd712f78334b3
- md5: 5b767048b1b3ee9a954b06f4084f93dc
+ size: 951405
+ timestamp: 1772818874251
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-15.2.0-h934c35e_18.conda
+ sha256: 78668020064fdaa27e9ab65cd2997e2c837b564ab26ce3bf0e58a2ce1a525c6e
+ md5: 1b08cd684f34175e4514474793d44bcb
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc 15.2.0 h767d61c_7
+ - libgcc 15.2.0 he0feb66_18
constrains:
- - libstdcxx-ng ==15.2.0=*_7
+ - libstdcxx-ng ==15.2.0=*_18
license: GPL-3.0-only WITH GCC-exception-3.1
license_family: GPL
purls: []
- size: 3898269
- timestamp: 1759968103436
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.9-h996ca69_0.conda
- sha256: 6b063df2d13dc9cedeae7b1591b1917ced7f4e1b04f7246e66cc7fb0088dea07
- md5: b6d222422c17dc11123e63fae4ad4178
+ size: 5852330
+ timestamp: 1771378262446
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libsystemd0-257.13-hd0affe5_0.conda
+ sha256: c5008b602cb5c819f7b52d418b3ed17e1818cbbf6705b189e7ab36bb70cce3d8
+ md5: 8ee3cb7f64be0e8c4787f3a4dbe024e6
depends:
- __glibc >=2.17,<3.0.a0
- - libcap >=2.76,<2.77.0a0
+ - libcap >=2.77,<2.78.0a0
- libgcc >=14
- - libgcrypt-lib >=1.11.1,<2.0a0
- - liblzma >=5.8.1,<6.0a0
- - lz4-c >=1.10.0,<1.11.0a0
- - zstd >=1.5.7,<1.6.0a0
license: LGPL-2.1-or-later
purls: []
- size: 492733
- timestamp: 1757520335407
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.9-h085a93f_0.conda
- sha256: 1c8f0b02c400617a9f2ea8429c604b28e25a10f51b3c8d73ce127b4e7b462297
- md5: 973f365f19c1d702bda523658a77de26
+ size: 492799
+ timestamp: 1773797095649
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libudev1-257.13-hd0affe5_0.conda
+ sha256: 1a1e367c04d66030aa93b4d33905f7f6fbb59cfc292e816fe3e9c1e8b3f4d1e2
+ md5: 2c2270f93d6f9073cbf72d821dfc7d72
depends:
- __glibc >=2.17,<3.0.a0
- - libcap >=2.76,<2.77.0a0
+ - libcap >=2.77,<2.78.0a0
- libgcc >=14
license: LGPL-2.1-or-later
purls: []
- size: 144265
- timestamp: 1757520342166
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.2-he9a06e4_0.conda
- sha256: e5ec6d2ad7eef538ddcb9ea62ad4346fde70a4736342c4ad87bd713641eb9808
- md5: 80c07c68d2f6870250959dcc95b209d1
+ size: 145087
+ timestamp: 1773797108513
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda
+ sha256: 1a7539cfa7df00714e8943e18de0b06cceef6778e420a5ee3a2a145773758aee
+ md5: db409b7c1720428638e7c0d509d3e1b5
depends:
- __glibc >=2.17,<3.0.a0
- libgcc >=14
license: BSD-3-Clause
license_family: BSD
purls: []
- size: 37135
- timestamp: 1758626800002
+ size: 40311
+ timestamp: 1766271528534
- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda
sha256: 6ae68e0b86423ef188196fff6207ed0c8195dd84273cb5623b85aa08033a410c
md5: 5aa797f8787fe7a17d1b0821485b5adc
@@ -2575,70 +2356,75 @@ packages:
purls: []
size: 100393
timestamp: 1702724383534
-- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.1-hb9d3cd8_2.conda
- sha256: d4bfe88d7cb447768e31650f06257995601f89076080e76df55e3112d4e47dc4
- md5: edb0dca6bc32e4f4789199455a1dbeb8
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_2.conda
+ sha256: 55044c403570f0dc26e6364de4dc5368e5f3fc7ff103e867c487e2b5ab2bcda9
+ md5: d87ff7921124eccd67248aa483c23fec
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
constrains:
- - zlib 1.3.1 *_2
+ - zlib 1.3.2 *_2
license: Zlib
license_family: Other
purls: []
- size: 60963
- timestamp: 1727963148474
-- pypi: https://files.pythonhosted.org/packages/fc/fb/38a48efe3e05a8e9a9765b991740282e0358a83fb896ec00d70bf1448791/litellm-1.78.0-py3-none-any.whl
+ size: 63629
+ timestamp: 1774072609062
+- pypi: https://files.pythonhosted.org/packages/5a/3a/590d58dee65a238f7f3d5c37f8f9f9021ecaf27fe379a393b4259324b56e/litellm-1.82.3-py3-none-any.whl
name: litellm
- version: 1.78.0
- sha256: a9d6deee882de8df38ca24beb930689f49209340137ff8a3dcab0c5fc4a0513d
+ version: 1.82.3
+ sha256: 609901f6c5a5cf8c24386e4e3f50738bb8a9db719709fd76b208c8ee6d00f7a7
requires_dist:
- - pyjwt>=2.8.0,<3.0.0 ; extra == 'proxy'
+ - pyjwt>=2.12.0,<3.0.0 ; python_full_version >= '3.9' and extra == 'proxy'
+ - a2a-sdk>=0.3.22,<0.4.0 ; python_full_version >= '3.10' and extra == 'extra-proxy'
- aiohttp>=3.10
- apscheduler>=3.10.4,<4.0.0 ; extra == 'proxy'
- - azure-identity>=1.15.0,<2.0.0 ; extra == 'extra-proxy' or extra == 'proxy'
+ - azure-identity>=1.15.0,<2.0.0 ; (python_full_version >= '3.9' and extra == 'extra-proxy') or (python_full_version >= '3.9' and extra == 'proxy')
- azure-keyvault-secrets>=4.8.0,<5.0.0 ; extra == 'extra-proxy'
- azure-storage-blob>=12.25.1,<13.0.0 ; extra == 'proxy'
- backoff ; extra == 'proxy'
- - boto3==1.36.0 ; extra == 'proxy'
+ - boto3>=1.40.76,<2.0.0 ; extra == 'proxy'
- click
- cryptography ; extra == 'proxy'
- diskcache>=5.6.1,<6.0.0 ; extra == 'caching'
- - fastapi>=0.115.5,<0.116.0 ; extra == 'proxy'
+ - fastapi>=0.120.1 ; extra == 'proxy'
- fastapi-sso>=0.16.0,<0.17.0 ; extra == 'proxy'
- fastuuid>=0.13.0
+ - google-cloud-aiplatform>=1.38.0 ; extra == 'google'
- google-cloud-iam>=2.19.1,<3.0.0 ; extra == 'extra-proxy'
- google-cloud-kms>=2.21.3,<3.0.0 ; extra == 'extra-proxy'
+ - grpcio>=1.62.3,!=1.68.*,!=1.69.*,!=1.70.*,!=1.71.0,!=1.71.1,!=1.72.0,!=1.72.1,!=1.73.0 ; python_full_version < '3.14' and extra == 'grpc'
+ - grpcio>=1.75.0 ; python_full_version >= '3.14' and extra == 'grpc'
- gunicorn>=23.0.0,<24.0.0 ; extra == 'proxy'
- httpx>=0.23.0
- importlib-metadata>=6.8.0
- jinja2>=3.1.2,<4.0.0
- - jsonschema>=4.22.0,<5.0.0
- - litellm-enterprise==0.1.20 ; extra == 'proxy'
- - litellm-proxy-extras==0.2.26 ; extra == 'proxy'
- - mcp>=1.10.0,<2.0.0 ; python_full_version >= '3.10' and extra == 'proxy'
+ - jsonschema>=4.23.0,<5.0.0
+ - litellm-enterprise>=0.1.33,<0.2.0 ; extra == 'proxy'
+ - litellm-proxy-extras>=0.4.56,<0.5.0 ; extra == 'proxy'
+ - mcp>=1.25.0,<2.0.0 ; python_full_version >= '3.10' and extra == 'proxy'
- mlflow>3.1.4 ; python_full_version >= '3.10' and extra == 'mlflow'
- numpydoc ; extra == 'utils'
- - openai>=1.99.5
+ - openai>=2.8.0
- orjson>=3.9.7,<4.0.0 ; extra == 'proxy'
- polars>=1.31.0,<2.0.0 ; python_full_version >= '3.10' and extra == 'proxy'
- - prisma==0.11.0 ; extra == 'extra-proxy'
+ - prisma>=0.11.0,<0.12.0 ; extra == 'extra-proxy'
- pydantic>=2.5.0,<3.0.0
- pynacl>=1.5.0,<2.0.0 ; extra == 'proxy'
+ - pyroscope-io>=0.8,<0.9 ; sys_platform != 'win32' and extra == 'proxy'
- python-dotenv>=0.2.0
- - python-multipart>=0.0.18,<0.0.19 ; extra == 'proxy'
+ - python-multipart>=0.0.20 ; extra == 'proxy'
- pyyaml>=6.0.1,<7.0.0 ; extra == 'proxy'
- redisvl>=0.4.1,<0.5.0 ; python_full_version >= '3.9' and python_full_version < '3.14' and extra == 'extra-proxy'
- - resend>=0.8.0,<0.9.0 ; extra == 'extra-proxy'
- - rich==13.7.1 ; extra == 'proxy'
+ - resend>=0.8.0 ; extra == 'extra-proxy'
+ - rich>=13.7.1,<14.0.0 ; extra == 'proxy'
- rq ; extra == 'proxy'
- - semantic-router ; python_full_version >= '3.9' and extra == 'semantic-router'
+ - semantic-router>=0.1.12 ; python_full_version >= '3.9' and python_full_version < '3.14' and extra == 'semantic-router'
+ - soundfile>=0.12.1,<0.13.0 ; extra == 'proxy'
- tiktoken>=0.7.0
- tokenizers
- - uvicorn>=0.29.0,<0.30.0 ; extra == 'proxy'
+ - uvicorn>=0.32.1,<1.0.0 ; extra == 'proxy'
- uvloop>=0.21.0,<0.22.0 ; sys_platform != 'win32' and extra == 'proxy'
- - websockets>=13.1.0,<14.0.0 ; extra == 'proxy'
- requires_python: '!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8'
+ - websockets>=15.0.1,<16.0.0 ; extra == 'proxy'
+ requires_python: '>=3.9,<4.0'
- pypi: https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl
name: loguru
version: 0.7.3
@@ -2667,93 +2453,92 @@ packages:
- mypy==1.4.1 ; python_full_version == '3.7.*' and extra == 'dev'
- mypy==1.13.0 ; python_full_version >= '3.8' and extra == 'dev'
- sphinx==8.1.3 ; python_full_version >= '3.11' and extra == 'dev'
- - sphinx-rtd-theme==3.0.2 ; python_full_version >= '3.11' and extra == 'dev'
- - myst-parser==4.0.0 ; python_full_version >= '3.11' and extra == 'dev'
- - build==1.2.2 ; python_full_version >= '3.11' and extra == 'dev'
- - twine==6.0.1 ; python_full_version >= '3.11' and extra == 'dev'
- requires_python: '>=3.5,<4.0'
-- pypi: https://files.pythonhosted.org/packages/c6/d1/232b3309a02d60f11e71857778bfcd4acbdb86c07db8260caf7d008b08f8/lxml-6.0.2-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl
- name: lxml
- version: 6.0.2
- sha256: 90a345bbeaf9d0587a3aaffb7006aa39ccb6ff0e96a57286c0cb2fd1520ea192
- requires_dist:
- - cssselect>=0.7 ; extra == 'cssselect'
- - html5lib ; extra == 'html5'
- - beautifulsoup4 ; extra == 'htmlsoup'
- - lxml-html-clean ; extra == 'html-clean'
- requires_python: '>=3.8'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/lz4-c-1.10.0-h5888daf_1.conda
- sha256: 47326f811392a5fd3055f0f773036c392d26fdb32e4d8e7a8197eed951489346
- md5: 9de5350a85c4a20c685259b889aa6393
- depends:
- - __glibc >=2.17,<3.0.a0
- - libgcc >=13
- - libstdcxx >=13
- license: BSD-2-Clause
- license_family: BSD
- purls: []
- size: 167055
- timestamp: 1733741040117
-- pypi: https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl
+ - sphinx-rtd-theme==3.0.2 ; python_full_version >= '3.11' and extra == 'dev'
+ - myst-parser==4.0.0 ; python_full_version >= '3.11' and extra == 'dev'
+ - build==1.2.2 ; python_full_version >= '3.11' and extra == 'dev'
+ - twine==6.0.1 ; python_full_version >= '3.11' and extra == 'dev'
+ requires_python: '>=3.5,<4.0'
+- pypi: https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl
name: markdown-it-py
- version: 4.0.0
- sha256: 87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147
+ version: 3.0.0
+ sha256: 355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1
requires_dist:
- mdurl~=0.1
- psutil ; extra == 'benchmarking'
- pytest ; extra == 'benchmarking'
- pytest-benchmark ; extra == 'benchmarking'
+ - pre-commit~=3.0 ; extra == 'code-style'
- commonmark~=0.9 ; extra == 'compare'
- markdown~=3.4 ; extra == 'compare'
- mistletoe~=1.0 ; extra == 'compare'
- - mistune~=3.0 ; extra == 'compare'
+ - mistune~=2.0 ; extra == 'compare'
- panflute~=2.3 ; extra == 'compare'
- - markdown-it-pyrs ; extra == 'compare'
- linkify-it-py>=1,<3 ; extra == 'linkify'
- - mdit-py-plugins>=0.5.0 ; extra == 'plugins'
+ - mdit-py-plugins ; extra == 'plugins'
- gprof2dot ; extra == 'profiling'
- - mdit-py-plugins>=0.5.0 ; extra == 'rtd'
+ - mdit-py-plugins ; extra == 'rtd'
- myst-parser ; extra == 'rtd'
- pyyaml ; extra == 'rtd'
- sphinx ; extra == 'rtd'
- sphinx-copybutton ; extra == 'rtd'
- sphinx-design ; extra == 'rtd'
- - sphinx-book-theme~=1.0 ; extra == 'rtd'
+ - sphinx-book-theme ; extra == 'rtd'
- jupyter-sphinx ; extra == 'rtd'
- - ipykernel ; extra == 'rtd'
- coverage ; extra == 'testing'
- pytest ; extra == 'testing'
- pytest-cov ; extra == 'testing'
- pytest-regressions ; extra == 'testing'
- - requests ; extra == 'testing'
- requires_python: '>=3.10'
+ requires_python: '>=3.8'
- pypi: https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: markupsafe
version: 3.0.3
sha256: d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d
requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl
+ name: mdit-py-plugins
+ version: 0.5.0
+ sha256: 07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f
+ requires_dist:
+ - markdown-it-py>=2.0.0,<5.0.0
+ - pre-commit ; extra == 'code-style'
+ - myst-parser ; extra == 'rtd'
+ - sphinx-book-theme ; extra == 'rtd'
+ - coverage ; extra == 'testing'
+ - pytest ; extra == 'testing'
+ - pytest-cov ; extra == 'testing'
+ - pytest-regressions ; extra == 'testing'
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl
name: mdurl
version: 0.1.2
sha256: 84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/fe/76/4ce12563aea5a76016f8643eff30ab731e6656c845e9e4d090ef10c7b925/mistralai-1.9.11-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/c9/f9/98d825105c450b9c67c27026caa374112b7e466c18331601d02ca278a01b/mistralai-1.12.4-py3-none-any.whl
name: mistralai
- version: 1.9.11
- sha256: 7a3dc2b8ef3fceaa3582220234261b5c4e3e03a972563b07afa150e44a25a6d3
+ version: 1.12.4
+ sha256: 7b69fcbc306436491ad3377fbdead527c9f3a0ce145ec029bf04c6308ff2cca6
requires_dist:
- - authlib>=1.5.2,<2.0 ; extra == 'agents'
- eval-type-backport>=0.2.0
- - google-auth>=2.27.0 ; extra == 'gcp'
- - griffe>=1.7.3,<2.0 ; extra == 'agents'
- httpx>=0.28.1
- invoke>=2.2.0,<3.0.0
- - mcp>=1.0,<2.0 ; python_full_version >= '3.10' and extra == 'agents'
+ - opentelemetry-api>=1.33.1,<2.0.0
+ - opentelemetry-exporter-otlp-proto-http>=1.37.0,<2.0.0
+ - opentelemetry-sdk>=1.33.1,<2.0.0
- pydantic>=2.10.3
- python-dateutil>=2.8.2
- pyyaml>=6.0.2,<7.0.0
- - requests>=2.32.3 ; extra == 'gcp'
- typing-inspection>=0.4.0
+ - authlib>=1.5.2,<2.0 ; extra == 'agents'
+ - griffe>=1.7.3,<2.0 ; extra == 'agents'
+ - mcp>=1.0,<2.0 ; extra == 'agents'
+ - google-auth>=2.27.0 ; extra == 'gcp'
+ - requests>=2.32.3 ; extra == 'gcp'
+ - websockets>=13.0 ; extra == 'realtime'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/a4/8e/469e5a4a2f5855992e425f3cb33804cc07bf18d48f2db061aec61ce50270/more_itertools-10.8.0-py3-none-any.whl
+ name: more-itertools
+ version: 10.8.0
+ sha256: 52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b
requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl
name: mpmath
@@ -2768,20 +2553,57 @@ packages:
- sphinx ; extra == 'docs'
- gmpy2>=2.1.0a4 ; platform_python_implementation != 'PyPy' and extra == 'gmpy'
- pytest>=4.6 ; extra == 'tests'
-- pypi: https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: multidict
- version: 6.7.0
- sha256: 123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d
+ version: 6.7.1
+ sha256: bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961
requires_dist:
- typing-extensions>=4.1.0 ; python_full_version < '3.11'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/0a/7d/a988f258104dcd2ccf1ed40fdc97e26c4ac351eeaf81d76e266c52d84e2f/multiprocess-0.70.16-py312-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/71/70/38998b950a97ea279e6bd657575d22d1a2047256caf707d9a10fbce4f065/multiprocess-0.70.19-py312-none-any.whl
name: multiprocess
- version: 0.70.16
- sha256: fc0544c531920dde3b00c29863377f87e1632601092ea2daca74e4beb40faa2e
+ version: 0.70.19
+ sha256: 3a56c0e85dd5025161bac5ce138dcac1e49174c7d8e74596537e729fd5c53c28
requires_dist:
- - dill>=0.3.8
- requires_python: '>=3.8'
+ - dill>=0.4.1
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/5f/df/76d0321c3797b54b60fef9ec3bd6f4cfd124b9e422182156a1dd418722cf/myst_parser-4.0.1-py3-none-any.whl
+ name: myst-parser
+ version: 4.0.1
+ sha256: 9134e88959ec3b5780aedf8a99680ea242869d012e8821db3126d427edc9c95d
+ requires_dist:
+ - docutils>=0.19,<0.22
+ - jinja2
+ - markdown-it-py~=3.0
+ - mdit-py-plugins~=0.4,>=0.4.1
+ - pyyaml
+ - sphinx>=7,<9
+ - pre-commit~=4.0 ; extra == 'code-style'
+ - linkify-it-py~=2.0 ; extra == 'linkify'
+ - sphinx>=7 ; extra == 'rtd'
+ - ipython ; extra == 'rtd'
+ - sphinx-book-theme~=1.1 ; extra == 'rtd'
+ - sphinx-design ; extra == 'rtd'
+ - sphinx-copybutton ; extra == 'rtd'
+ - sphinxext-rediraffe~=0.2.7 ; extra == 'rtd'
+ - sphinxext-opengraph~=0.9.0 ; extra == 'rtd'
+ - sphinx-pyscript ; extra == 'rtd'
+ - sphinx-tippy>=0.4.3 ; extra == 'rtd'
+ - sphinx-autodoc2~=0.5.0 ; extra == 'rtd'
+ - sphinx-togglebutton ; extra == 'rtd'
+ - beautifulsoup4 ; extra == 'testing'
+ - coverage[toml] ; extra == 'testing'
+ - defusedxml ; extra == 'testing'
+ - pytest>=8,<9 ; extra == 'testing'
+ - pytest-cov ; extra == 'testing'
+ - pytest-regressions ; extra == 'testing'
+ - pytest-param-files~=0.6.0 ; extra == 'testing'
+ - sphinx-pytest ; extra == 'testing'
+ - pygments<2.19 ; extra == 'testing'
+ - pygments ; extra == 'testing-docutils'
+ - pytest>=8,<9 ; extra == 'testing-docutils'
+ - pytest-param-files~=0.6.0 ; extra == 'testing-docutils'
+ requires_python: '>=3.10'
- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda
sha256: 3fde293232fa3fca98635e1167de6b7c7fda83caf24b9d6c91ec9eefb4f4d586
md5: 47e340acb35de30501a76c7c799c41d7
@@ -2792,11 +2614,13 @@ packages:
purls: []
size: 891641
timestamp: 1738195959188
-- pypi: https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl
name: networkx
- version: '3.5'
- sha256: 0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec
+ version: 3.6.1
+ sha256: d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762
requires_dist:
+ - asv ; extra == 'benchmarking'
+ - virtualenv ; extra == 'benchmarking'
- numpy>=1.25 ; extra == 'default'
- scipy>=1.11.2 ; extra == 'default'
- matplotlib>=3.8 ; extra == 'default'
@@ -2818,20 +2642,30 @@ packages:
- cairocffi>=1.7 ; extra == 'example'
- igraph>=0.11 ; extra == 'example'
- scikit-learn>=1.5 ; extra == 'example'
+ - iplotx>=0.9.0 ; extra == 'example'
- lxml>=4.6 ; extra == 'extra'
- pygraphviz>=1.14 ; extra == 'extra'
- pydot>=3.0.1 ; extra == 'extra'
- sympy>=1.10 ; extra == 'extra'
+ - build>=0.10 ; extra == 'release'
+ - twine>=4.0 ; extra == 'release'
+ - wheel>=0.40 ; extra == 'release'
+ - changelist==0.5 ; extra == 'release'
- pytest>=7.2 ; extra == 'test'
- pytest-cov>=4.0 ; extra == 'test'
- pytest-xdist>=3.0 ; extra == 'test'
- pytest-mpl ; extra == 'test-extras'
- pytest-randomly ; extra == 'test-extras'
- requires_python: '>=3.11'
-- pypi: https://files.pythonhosted.org/packages/60/90/81ac364ef94209c100e12579629dc92bf7a709a84af32f8c551b02c07e94/nltk-3.9.2-py3-none-any.whl
+ requires_python: '>=3.11,!=3.14.1'
+- pypi: https://files.pythonhosted.org/packages/6f/84/c0dc75c7fb596135f999e59a410d9f45bdabb989f1cb911f0016d22b747b/nh3-0.3.3-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: nh3
+ version: 0.3.3
+ sha256: e98fa3dbfd54e25487e36ba500bc29bca3a4cab4ffba18cfb1a35a2d02624297
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/c2/7e/9af5a710a1236e4772de8dfcc6af942a561327bb9f42b5b4a24d0cf100fd/nltk-3.9.3-py3-none-any.whl
name: nltk
- version: 3.9.2
- sha256: 1e209d2b3009110635ed9709a67a1a3e33a10f799490fa71cf4bec218c11c88a
+ version: 3.9.3
+ sha256: 60b3db6e9995b3dd976b1f0fa7dec22069b2677e759c28eb69b62ddd44870522
requires_dist:
- click
- joblib
@@ -2845,24 +2679,24 @@ packages:
- pyparsing ; extra == 'tgrep'
- twython ; extra == 'twitter'
- requests ; extra == 'corenlp'
- - matplotlib ; extra == 'all'
- - numpy ; extra == 'all'
- - scipy ; extra == 'all'
- - twython ; extra == 'all'
- - requests ; extra == 'all'
- python-crfsuite ; extra == 'all'
- pyparsing ; extra == 'all'
+ - scipy ; extra == 'all'
+ - numpy ; extra == 'all'
- scikit-learn ; extra == 'all'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl
+ - matplotlib ; extra == 'all'
+ - requests ; extra == 'all'
+ - twython ; extra == 'all'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl
name: nodeenv
- version: 1.9.1
- sha256: ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
+ version: 1.10.0
+ sha256: 5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827
requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*'
-- pypi: https://files.pythonhosted.org/packages/f9/58/30a85127bfee6f108282107caf8e06a1f0cc997cb6b52cdee699276fcce4/numpy-2.3.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
name: numpy
- version: 2.3.4
- sha256: 4121c5beb58a7f9e6dfdee612cb24f4df5cd4db6e8261d7f4d7450a997a65d6a
+ version: 2.4.3
+ sha256: e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97
requires_python: '>=3.11'
- pypi: https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl
name: nvidia-cublas-cu12
@@ -2938,10 +2772,10 @@ packages:
version: 12.8.93
sha256: 81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88
requires_python: '>=3'
-- pypi: https://files.pythonhosted.org/packages/3b/6c/99acb2f9eb85c29fc6f3a7ac4dccfd992e22666dd08a642b303311326a97/nvidia_nvshmem_cu12-3.3.20-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
name: nvidia-nvshmem-cu12
- version: 3.3.20
- sha256: d00f26d3f9b2e3c3065be895e3059d6479ea5c638a3f38c9fec49b1b9dd7c1e5
+ version: 3.4.5
+ sha256: 042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd
requires_python: '>=3'
- pypi: https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
name: nvidia-nvtx-cu12
@@ -2960,27 +2794,10 @@ packages:
purls: []
size: 106742
timestamp: 1743700382939
-- pypi: https://files.pythonhosted.org/packages/6c/d9/b7140a4f1615195938c7e358c0804bb84271f0d6886b5cbf105c6cb58aae/onnxruntime_gpu-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- name: onnxruntime-gpu
- version: 1.23.2
- sha256: 4f2d1f720685d729b5258ec1b36dee1de381b8898189908c98cbeecdb2f2b5c2
- requires_dist:
- - coloredlogs
- - flatbuffers
- - numpy>=1.21.6
- - packaging
- - protobuf
- - sympy
- - nvidia-cuda-nvrtc-cu12~=12.0 ; extra == 'cuda'
- - nvidia-cuda-runtime-cu12~=12.0 ; extra == 'cuda'
- - nvidia-cufft-cu12~=11.0 ; extra == 'cuda'
- - nvidia-curand-cu12~=10.0 ; extra == 'cuda'
- - nvidia-cudnn-cu12~=9.0 ; extra == 'cudnn'
- requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/9c/5b/4be258ff072ed8ee15f6bfd8d5a1a4618aa4704b127c0c5959212ad177d6/openai-2.3.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl
name: openai
- version: 2.3.0
- sha256: a7aa83be6f7b0ab2e4d4d7bcaf36e3d790874c0167380c5d0afd0ed99a86bd7b
+ version: 2.29.0
+ sha256: b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a
requires_dist:
- anyio>=3.5.0,<5
- distro>=1.7.0,<2
@@ -2990,30 +2807,31 @@ packages:
- sniffio
- tqdm>4
- typing-extensions>=4.11,<5
+ - typing-extensions>=4.14,<5
- aiohttp ; extra == 'aiohttp'
- - httpx-aiohttp>=0.1.8 ; extra == 'aiohttp'
+ - httpx-aiohttp>=0.1.9 ; extra == 'aiohttp'
- numpy>=1 ; extra == 'datalib'
- pandas-stubs>=1.1.0.11 ; extra == 'datalib'
- pandas>=1.2.3 ; extra == 'datalib'
- websockets>=13,<16 ; extra == 'realtime'
- numpy>=2.0.2 ; extra == 'voice-helpers'
- sounddevice>=0.5.1 ; extra == 'voice-helpers'
- requires_python: '>=3.8'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-h5888daf_0.conda
- sha256: 2b6ce54174ec19110e1b3c37455f7cd138d0e228a75727a9bba443427da30a36
- md5: 45c3d2c224002d6d0d7769142b29f986
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/linux-64/opencl-headers-2025.06.13-hecca717_0.conda
+ sha256: 8de2f0cd8a659b01abf86e7fbb8cea4f28ada62fd288429a2bbc040db1b98dd0
+ md5: c930c8052d780caa41216af7de472226
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
- - libstdcxx >=13
+ - libgcc >=14
+ - libstdcxx >=14
license: Apache-2.0
license_family: APACHE
purls: []
- size: 55357
- timestamp: 1749853464518
-- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.5.4-h26f9b46_0.conda
- sha256: e807f3bad09bdf4075dbb4168619e14b0c0360bacb2e12ef18641a834c8c5549
- md5: 14edad12b59ccbfa3910d42c72adc2a0
+ size: 55754
+ timestamp: 1773844383536
+- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.1-h35e630c_1.conda
+ sha256: 44c877f8af015332a5d12f5ff0fb20ca32f896526a7d0cdb30c769df1144fb5c
+ md5: f61eb8cd60ff9057122a3d338b99c00f
depends:
- __glibc >=2.17,<3.0.a0
- ca-certificates
@@ -3021,108 +2839,167 @@ packages:
license: Apache-2.0
license_family: Apache
purls: []
- size: 3119624
- timestamp: 1759324353651
-- pypi: https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl
- name: packaging
- version: '25.0'
- sha256: 29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484
- requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/e5/63/cd7d615331b328e287d8233ba9fdf191a9c2d11b6af0c7a59cfcec23de68/pandas-2.3.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
+ size: 3164551
+ timestamp: 1769555830639
+- pypi: https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl
+ name: opentelemetry-api
+ version: 1.40.0
+ sha256: 82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9
+ requires_dist:
+ - importlib-metadata>=6.0,<8.8.0
+ - typing-extensions>=4.5.0
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/8b/ca/8f122055c97a932311a3f640273f084e738008933503d0c2563cd5d591fc/opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl
+ name: opentelemetry-exporter-otlp-proto-common
+ version: 1.40.0
+ sha256: 7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149
+ requires_dist:
+ - opentelemetry-proto==1.40.0
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/a0/3a/8865d6754e61c9fb170cdd530a124a53769ee5f740236064816eb0ca7301/opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl
+ name: opentelemetry-exporter-otlp-proto-http
+ version: 1.40.0
+ sha256: a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069
+ requires_dist:
+ - googleapis-common-protos~=1.52
+ - opentelemetry-api~=1.15
+ - opentelemetry-exporter-otlp-proto-common==1.40.0
+ - opentelemetry-proto==1.40.0
+ - opentelemetry-sdk~=1.40.0
+ - requests~=2.7
+ - typing-extensions>=4.5.0
+ - opentelemetry-exporter-credential-provider-gcp>=0.59b0 ; extra == 'gcp-auth'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/b9/b2/189b2577dde745b15625b3214302605b1353436219d42b7912e77fa8dc24/opentelemetry_proto-1.40.0-py3-none-any.whl
+ name: opentelemetry-proto
+ version: 1.40.0
+ sha256: 266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f
+ requires_dist:
+ - protobuf>=5.0,<7.0
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl
+ name: opentelemetry-sdk
+ version: 1.40.0
+ sha256: 787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1
+ requires_dist:
+ - opentelemetry-api==1.40.0
+ - opentelemetry-semantic-conventions==0.61b0
+ - typing-extensions>=4.5.0
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl
+ name: opentelemetry-semantic-conventions
+ version: 0.61b0
+ sha256: fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2
+ requires_dist:
+ - opentelemetry-api==1.40.0
+ - typing-extensions>=4.5.0
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda
+ sha256: c1fc0f953048f743385d31c468b4a678b3ad20caffdeaa94bed85ba63049fd58
+ md5: b76541e68fea4d511b1ac46a28dcd2c6
+ depends:
+ - python >=3.8
+ - python
+ license: Apache-2.0
+ license_family: APACHE
+ purls:
+ - pkg:pypi/packaging?source=compressed-mapping
+ size: 72010
+ timestamp: 1769093650580
+- pypi: https://files.pythonhosted.org/packages/3d/fe/89d77e424365280b79d99b3e1e7d606f5165af2f2ecfaf0c6d24c799d607/pandas-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
name: pandas
- version: 2.3.3
- sha256: b3d11d2fda7eb164ef27ffc14b4fcab16a80e1ce67e9f57e19ec0afaf715ba89
+ version: 3.0.1
+ sha256: 532527a701281b9dd371e2f582ed9094f4c12dd9ffb82c0c54ee28d8ac9520c4
requires_dist:
- - numpy>=1.22.4 ; python_full_version < '3.11'
- - numpy>=1.23.2 ; python_full_version == '3.11.*'
- - numpy>=1.26.0 ; python_full_version >= '3.12'
+ - numpy>=1.26.0 ; python_full_version < '3.14'
+ - numpy>=2.3.3 ; python_full_version >= '3.14'
- python-dateutil>=2.8.2
- - pytz>=2020.1
- - tzdata>=2022.7
- - hypothesis>=6.46.1 ; extra == 'test'
- - pytest>=7.3.2 ; extra == 'test'
- - pytest-xdist>=2.2.0 ; extra == 'test'
- - pyarrow>=10.0.1 ; extra == 'pyarrow'
- - bottleneck>=1.3.6 ; extra == 'performance'
- - numba>=0.56.4 ; extra == 'performance'
- - numexpr>=2.8.4 ; extra == 'performance'
- - scipy>=1.10.0 ; extra == 'computation'
- - xarray>=2022.12.0 ; extra == 'computation'
- - fsspec>=2022.11.0 ; extra == 'fss'
- - s3fs>=2022.11.0 ; extra == 'aws'
- - gcsfs>=2022.11.0 ; extra == 'gcp'
- - pandas-gbq>=0.19.0 ; extra == 'gcp'
+ - tzdata ; sys_platform == 'win32'
+ - tzdata ; sys_platform == 'emscripten'
+ - hypothesis>=6.116.0 ; extra == 'test'
+ - pytest>=8.3.4 ; extra == 'test'
+ - pytest-xdist>=3.6.1 ; extra == 'test'
+ - pyarrow>=13.0.0 ; extra == 'pyarrow'
+ - bottleneck>=1.4.2 ; extra == 'performance'
+ - numba>=0.60.0 ; extra == 'performance'
+ - numexpr>=2.10.2 ; extra == 'performance'
+ - scipy>=1.14.1 ; extra == 'computation'
+ - xarray>=2024.10.0 ; extra == 'computation'
+ - fsspec>=2024.10.0 ; extra == 'fss'
+ - s3fs>=2024.10.0 ; extra == 'aws'
+ - gcsfs>=2024.10.0 ; extra == 'gcp'
- odfpy>=1.4.1 ; extra == 'excel'
- - openpyxl>=3.1.0 ; extra == 'excel'
- - python-calamine>=0.1.7 ; extra == 'excel'
+ - openpyxl>=3.1.5 ; extra == 'excel'
+ - python-calamine>=0.3.0 ; extra == 'excel'
- pyxlsb>=1.0.10 ; extra == 'excel'
- xlrd>=2.0.1 ; extra == 'excel'
- - xlsxwriter>=3.0.5 ; extra == 'excel'
- - pyarrow>=10.0.1 ; extra == 'parquet'
- - pyarrow>=10.0.1 ; extra == 'feather'
- - tables>=3.8.0 ; extra == 'hdf5'
- - pyreadstat>=1.2.0 ; extra == 'spss'
- - sqlalchemy>=2.0.0 ; extra == 'postgresql'
- - psycopg2>=2.9.6 ; extra == 'postgresql'
- - adbc-driver-postgresql>=0.8.0 ; extra == 'postgresql'
- - sqlalchemy>=2.0.0 ; extra == 'mysql'
- - pymysql>=1.0.2 ; extra == 'mysql'
- - sqlalchemy>=2.0.0 ; extra == 'sql-other'
- - adbc-driver-postgresql>=0.8.0 ; extra == 'sql-other'
- - adbc-driver-sqlite>=0.8.0 ; extra == 'sql-other'
- - beautifulsoup4>=4.11.2 ; extra == 'html'
+ - xlsxwriter>=3.2.0 ; extra == 'excel'
+ - pyarrow>=13.0.0 ; extra == 'parquet'
+ - pyarrow>=13.0.0 ; extra == 'feather'
+ - pyiceberg>=0.8.1 ; extra == 'iceberg'
+ - tables>=3.10.1 ; extra == 'hdf5'
+ - pyreadstat>=1.2.8 ; extra == 'spss'
+ - sqlalchemy>=2.0.36 ; extra == 'postgresql'
+ - psycopg2>=2.9.10 ; extra == 'postgresql'
+ - adbc-driver-postgresql>=1.2.0 ; extra == 'postgresql'
+ - sqlalchemy>=2.0.36 ; extra == 'mysql'
+ - pymysql>=1.1.1 ; extra == 'mysql'
+ - sqlalchemy>=2.0.36 ; extra == 'sql-other'
+ - adbc-driver-postgresql>=1.2.0 ; extra == 'sql-other'
+ - adbc-driver-sqlite>=1.2.0 ; extra == 'sql-other'
+ - beautifulsoup4>=4.12.3 ; extra == 'html'
- html5lib>=1.1 ; extra == 'html'
- - lxml>=4.9.2 ; extra == 'html'
- - lxml>=4.9.2 ; extra == 'xml'
- - matplotlib>=3.6.3 ; extra == 'plot'
- - jinja2>=3.1.2 ; extra == 'output-formatting'
+ - lxml>=5.3.0 ; extra == 'html'
+ - lxml>=5.3.0 ; extra == 'xml'
+ - matplotlib>=3.9.3 ; extra == 'plot'
+ - jinja2>=3.1.5 ; extra == 'output-formatting'
- tabulate>=0.9.0 ; extra == 'output-formatting'
- pyqt5>=5.15.9 ; extra == 'clipboard'
- - qtpy>=2.3.0 ; extra == 'clipboard'
- - zstandard>=0.19.0 ; extra == 'compression'
- - dataframe-api-compat>=0.1.7 ; extra == 'consortium-standard'
- - adbc-driver-postgresql>=0.8.0 ; extra == 'all'
- - adbc-driver-sqlite>=0.8.0 ; extra == 'all'
- - beautifulsoup4>=4.11.2 ; extra == 'all'
- - bottleneck>=1.3.6 ; extra == 'all'
- - dataframe-api-compat>=0.1.7 ; extra == 'all'
- - fastparquet>=2022.12.0 ; extra == 'all'
- - fsspec>=2022.11.0 ; extra == 'all'
- - gcsfs>=2022.11.0 ; extra == 'all'
+ - qtpy>=2.4.2 ; extra == 'clipboard'
+ - zstandard>=0.23.0 ; extra == 'compression'
+ - pytz>=2024.2 ; extra == 'timezone'
+ - adbc-driver-postgresql>=1.2.0 ; extra == 'all'
+ - adbc-driver-sqlite>=1.2.0 ; extra == 'all'
+ - beautifulsoup4>=4.12.3 ; extra == 'all'
+ - bottleneck>=1.4.2 ; extra == 'all'
+ - fastparquet>=2024.11.0 ; extra == 'all'
+ - fsspec>=2024.10.0 ; extra == 'all'
+ - gcsfs>=2024.10.0 ; extra == 'all'
- html5lib>=1.1 ; extra == 'all'
- - hypothesis>=6.46.1 ; extra == 'all'
- - jinja2>=3.1.2 ; extra == 'all'
- - lxml>=4.9.2 ; extra == 'all'
- - matplotlib>=3.6.3 ; extra == 'all'
- - numba>=0.56.4 ; extra == 'all'
- - numexpr>=2.8.4 ; extra == 'all'
+ - hypothesis>=6.116.0 ; extra == 'all'
+ - jinja2>=3.1.5 ; extra == 'all'
+ - lxml>=5.3.0 ; extra == 'all'
+ - matplotlib>=3.9.3 ; extra == 'all'
+ - numba>=0.60.0 ; extra == 'all'
+ - numexpr>=2.10.2 ; extra == 'all'
- odfpy>=1.4.1 ; extra == 'all'
- - openpyxl>=3.1.0 ; extra == 'all'
- - pandas-gbq>=0.19.0 ; extra == 'all'
- - psycopg2>=2.9.6 ; extra == 'all'
- - pyarrow>=10.0.1 ; extra == 'all'
- - pymysql>=1.0.2 ; extra == 'all'
+ - openpyxl>=3.1.5 ; extra == 'all'
+ - psycopg2>=2.9.10 ; extra == 'all'
+ - pyarrow>=13.0.0 ; extra == 'all'
+ - pyiceberg>=0.8.1 ; extra == 'all'
+ - pymysql>=1.1.1 ; extra == 'all'
- pyqt5>=5.15.9 ; extra == 'all'
- - pyreadstat>=1.2.0 ; extra == 'all'
- - pytest>=7.3.2 ; extra == 'all'
- - pytest-xdist>=2.2.0 ; extra == 'all'
- - python-calamine>=0.1.7 ; extra == 'all'
+ - pyreadstat>=1.2.8 ; extra == 'all'
+ - pytest>=8.3.4 ; extra == 'all'
+ - pytest-xdist>=3.6.1 ; extra == 'all'
+ - python-calamine>=0.3.0 ; extra == 'all'
+ - pytz>=2024.2 ; extra == 'all'
- pyxlsb>=1.0.10 ; extra == 'all'
- - qtpy>=2.3.0 ; extra == 'all'
- - scipy>=1.10.0 ; extra == 'all'
- - s3fs>=2022.11.0 ; extra == 'all'
- - sqlalchemy>=2.0.0 ; extra == 'all'
- - tables>=3.8.0 ; extra == 'all'
+ - qtpy>=2.4.2 ; extra == 'all'
+ - scipy>=1.14.1 ; extra == 'all'
+ - s3fs>=2024.10.0 ; extra == 'all'
+ - sqlalchemy>=2.0.36 ; extra == 'all'
+ - tables>=3.10.1 ; extra == 'all'
- tabulate>=0.9.0 ; extra == 'all'
- - xarray>=2022.12.0 ; extra == 'all'
+ - xarray>=2024.10.0 ; extra == 'all'
- xlrd>=2.0.1 ; extra == 'all'
- - xlsxwriter>=3.0.5 ; extra == 'all'
- - zstandard>=0.19.0 ; extra == 'all'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/4f/87/424511bdcd02c8d7acf9f65caa09f291a519b16bd83c3fb3374b3d4ae951/pillow-12.0.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+ - xlsxwriter>=3.2.0 ; extra == 'all'
+ - zstandard>=0.23.0 ; extra == 'all'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
name: pillow
- version: 12.0.0
- sha256: b87843e225e74576437fd5b6a4c2205d422754f84a06942cfaf1dc32243e45a8
+ version: 11.3.0
+ sha256: 676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024
requires_dist:
- furo ; extra == 'docs'
- olefile ; extra == 'docs'
@@ -3133,9 +3010,6 @@ packages:
- sphinxext-opengraph ; extra == 'docs'
- olefile ; extra == 'fpx'
- olefile ; extra == 'mic'
- - arro3-compute ; extra == 'test-arrow'
- - arro3-core ; extra == 'test-arrow'
- - nanoarrow ; extra == 'test-arrow'
- pyarrow ; extra == 'test-arrow'
- check-manifest ; extra == 'tests'
- coverage>=7.4.2 ; extra == 'tests'
@@ -3143,42 +3017,32 @@ packages:
- markdown2 ; extra == 'tests'
- olefile ; extra == 'tests'
- packaging ; extra == 'tests'
- - pyroma>=5 ; extra == 'tests'
+ - pyroma ; extra == 'tests'
- pytest ; extra == 'tests'
- pytest-cov ; extra == 'tests'
- pytest-timeout ; extra == 'tests'
- pytest-xdist ; extra == 'tests'
- trove-classifiers>=2024.10.12 ; extra == 'tests'
+ - typing-extensions ; python_full_version < '3.10' and extra == 'typing'
- defusedxml ; extra == 'xmp'
- requires_python: '>=3.10'
-- conda: https://conda.anaconda.org/conda-forge/noarch/pip-25.2-pyh8b19718_0.conda
- sha256: ec9ed3cef137679f3e3a68e286c6efd52144684e1be0b05004d9699882dadcdd
- md5: dfce4b2af4bfe90cdcaf56ca0b28ddf5
+ requires_python: '>=3.9'
+- conda: https://conda.anaconda.org/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda
+ sha256: 8e1497814a9997654ed7990a79c054ea5a42545679407acbc6f7e809c73c9120
+ md5: 67bdec43082fd8a9cffb9484420b39a2
depends:
- - python >=3.9,<3.13.0a0
+ - python >=3.10,<3.13.0a0
- setuptools
- wheel
license: MIT
license_family: MIT
purls:
- - pkg:pypi/pip?source=hash-mapping
- size: 1177168
- timestamp: 1753924973872
-- pypi: https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl
+ - pkg:pypi/pip?source=compressed-mapping
+ size: 1181790
+ timestamp: 1770270305795
+- pypi: https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl
name: platformdirs
- version: 4.5.0
- sha256: e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3
- requires_dist:
- - furo>=2025.9.25 ; extra == 'docs'
- - proselint>=0.14 ; extra == 'docs'
- - sphinx-autodoc-typehints>=3.2 ; extra == 'docs'
- - sphinx>=8.2.3 ; extra == 'docs'
- - appdirs==1.4.4 ; extra == 'test'
- - covdefaults>=2.3 ; extra == 'test'
- - pytest-cov>=7 ; extra == 'test'
- - pytest-mock>=3.15.1 ; extra == 'test'
- - pytest>=8.4.2 ; extra == 'test'
- - mypy>=1.18.2 ; extra == 'type'
+ version: 4.9.4
+ sha256: 68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868
requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl
name: pluggy
@@ -3191,43 +3055,35 @@ packages:
- pytest-benchmark ; extra == 'testing'
- coverage ; extra == 'testing'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl
name: pre-commit
- version: 4.3.0
- sha256: 2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8
+ version: 4.5.1
+ sha256: 3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77
requires_dist:
- cfgv>=2.0.0
- identify>=1.0.0
- nodeenv>=0.11.1
- pyyaml>=5.1
- virtualenv>=20.10.0
- requires_python: '>=3.9'
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: propcache
version: 0.4.1
sha256: 15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl
- name: proto-plus
- version: 1.26.1
- sha256: 13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66
- requires_dist:
- - protobuf>=3.19.0,<7.0.0
- - google-api-core>=1.31.5 ; extra == 'testing'
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/21/51/0b1cbad62074439b867b4e04cc09b93f6699d78fd191bed2bbb44562e077/protobuf-6.33.0-cp39-abi3-manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl
name: protobuf
- version: 6.33.0
- sha256: 35be49fd3f4fefa4e6e2aacc35e8b837d6703c37a2168a55ac21e9b1bc7559ef
+ version: 6.33.6
+ sha256: e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/30/28/5e27f4d5a0e347f8e3cc16cd7d35533dbce086c95807f1f0e9cd77e26c10/psutil-7.1.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl
name: psutil
- version: 7.1.2
- sha256: 3efd8fc791492e7808a51cb2b94889db7578bfaea22df931424f874468e389e3
+ version: 7.2.2
+ sha256: 076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9
requires_dist:
+ - psleak ; extra == 'dev'
- pytest ; extra == 'dev'
- pytest-instafail ; extra == 'dev'
- - pytest-subtests ; extra == 'dev'
- pytest-xdist ; extra == 'dev'
- setuptools ; extra == 'dev'
- abi3audit ; extra == 'dev'
@@ -3250,18 +3106,19 @@ packages:
- virtualenv ; extra == 'dev'
- vulture ; extra == 'dev'
- wheel ; extra == 'dev'
- - pyreadline ; os_name == 'nt' and extra == 'dev'
- - pywin32 ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'dev'
- - wheel ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'dev'
- - wmi ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'dev'
+ - colorama ; os_name == 'nt' and extra == 'dev'
+ - pyreadline3 ; os_name == 'nt' and extra == 'dev'
+ - pywin32 ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev'
+ - wheel ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev'
+ - wmi ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'dev'
+ - psleak ; extra == 'test'
- pytest ; extra == 'test'
- pytest-instafail ; extra == 'test'
- - pytest-subtests ; extra == 'test'
- pytest-xdist ; extra == 'test'
- setuptools ; extra == 'test'
- - pywin32 ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'test'
- - wheel ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'test'
- - wmi ; os_name == 'nt' and platform_python_implementation != 'PyPy' and extra == 'test'
+ - pywin32 ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test'
+ - wheel ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test'
+ - wmi ; implementation_name != 'pypy' and os_name == 'nt' and extra == 'test'
requires_python: '>=3.6'
- pypi: https://files.pythonhosted.org/packages/d7/64/0ea5be39e6a6515804cae8c280226d771f42750a08182f9d2e5f3b822694/PyArabic-0.6.15-py3-none-any.whl
name: pyarabic
@@ -3269,21 +3126,15 @@ packages:
sha256: b9a530277876008f5fbe53249c6953b4513dbbf2ea8f4339694e87c8a75d7edf
requires_dist:
- six>=1.14.0
-- pypi: https://files.pythonhosted.org/packages/ad/90/2660332eeb31303c13b653ea566a9918484b6e4d6b9d2d46879a33ab0622/pyarrow-21.0.0-cp312-cp312-manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/2c/a5/da83046273d990f256cb79796a190bbf7ec999269705ddc609403f8c6b06/pyarrow-23.0.1-cp312-cp312-manylinux_2_28_x86_64.whl
name: pyarrow
- version: 21.0.0
- sha256: b7ae0bbdc8c6674259b25bef5d2a1d6af5d39d7200c819cf99e07f7dfef1c51e
- requires_dist:
- - pytest ; extra == 'test'
- - hypothesis ; extra == 'test'
- - cffi ; extra == 'test'
- - pytz ; extra == 'test'
- - pandas ; extra == 'test'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl
+ version: 23.0.1
+ sha256: 813d99f31275919c383aab17f0f455a04f5a429c261cc411b1e9a8f5e4aaaa05
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl
name: pyasn1
- version: 0.6.1
- sha256: 0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629
+ version: 0.6.3
+ sha256: a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde
requires_python: '>=3.8'
- pypi: https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl
name: pyasn1-modules
@@ -3292,25 +3143,85 @@ packages:
requires_dist:
- pyasn1>=0.6.1,<0.7.0
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/6c/98/468cb649f208a6f1279448e6e5247b37ae79cf5e4041186f1e2ef3d16345/pydantic-2.12.2-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl
+ name: pycparser
+ version: '3.0'
+ sha256: b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl
name: pydantic
- version: 2.12.2
- sha256: 25ff718ee909acd82f1ff9b1a4acfd781bb23ab3739adaa7144f19a6a4e231ae
+ version: 2.12.5
+ sha256: e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d
requires_dist:
- annotated-types>=0.6.0
- - pydantic-core==2.41.4
+ - pydantic-core==2.41.5
- typing-extensions>=4.14.1
- typing-inspection>=0.4.2
- email-validator>=2.0.0 ; extra == 'email'
- tzdata ; python_full_version >= '3.9' and sys_platform == 'win32' and extra == 'timezone'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/f0/83/06d001f8043c336baea7fd202a9ac7ad71f87e1c55d8112c50b745c40324/pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: pydantic-core
- version: 2.41.4
- sha256: 98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47
+ version: 2.41.5
+ sha256: eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c
requires_dist:
- typing-extensions>=4.14.1
requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/9d/b7/a2bae25aae3568fe9f17040b31f9c190b4c5d86856d8869d0a30364a2567/pydata_sphinx_theme-0.17.0-py3-none-any.whl
+ name: pydata-sphinx-theme
+ version: 0.17.0
+ sha256: cec5c92f41f4a11541b6df8210c446b4aa9c3badb7fcf2db7893405b786d5c99
+ requires_dist:
+ - sphinx>=7.0,<10
+ - beautifulsoup4
+ - docutils!=0.17.0
+ - babel
+ - pygments>=2.7
+ - accessible-pygments
+ - typing-extensions
+ - astroid>=3,!=4.0.3 ; extra == 'doc'
+ - numpydoc ; extra == 'doc'
+ - linkify-it-py ; extra == 'doc'
+ - rich ; extra == 'doc'
+ - sphinxext-rediraffe ; extra == 'doc'
+ - sphinx-sitemap<2.7.0 ; extra == 'doc'
+ - sphinx-autoapi==3.6.1 ; extra == 'doc'
+ - myst-parser ; extra == 'doc'
+ - ablog>=0.11.8 ; extra == 'doc'
+ - jupyter-sphinx ; extra == 'doc'
+ - pandas ; extra == 'doc'
+ - plotly ; extra == 'doc'
+ - matplotlib ; extra == 'doc'
+ - numpy ; extra == 'doc'
+ - xarray ; extra == 'doc'
+ - sphinx-copybutton ; extra == 'doc'
+ - sphinx-design ; extra == 'doc'
+ - sphinx-togglebutton ; extra == 'doc'
+ - jupyterlite-sphinx ; extra == 'doc'
+ - sphinxcontrib-mermaid ; extra == 'doc'
+ - sphinxcontrib-youtube>=1.4.1 ; extra == 'doc'
+ - sphinx-favicon>=1.0.1 ; extra == 'doc'
+ - ipykernel ; extra == 'doc'
+ - nbsphinx ; extra == 'doc'
+ - ipyleaflet ; extra == 'doc'
+ - colorama ; extra == 'doc'
+ - ipywidgets ; extra == 'doc'
+ - graphviz ; extra == 'doc'
+ - pyyaml ; extra == 'dev'
+ - pre-commit ; extra == 'dev'
+ - pydata-sphinx-theme[doc,test] ; extra == 'dev'
+ - tox ; extra == 'dev'
+ - pandoc ; extra == 'dev'
+ - sphinx-theme-builder[cli] ; extra == 'dev'
+ - pytest ; extra == 'test'
+ - pytest-cov ; extra == 'test'
+ - pytest-regressions ; extra == 'test'
+ - sphinx[test] ; extra == 'test'
+ - pytest-playwright ; extra == 'test'
+ - pydata-sphinx-theme[test] ; extra == 'a11y'
+ - babel ; extra == 'i18n'
+ - jinja2 ; extra == 'i18n'
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl
name: pygments
version: 2.19.2
@@ -3318,28 +3229,25 @@ packages:
requires_dist:
- colorama>=0.4.6 ; extra == 'windows-terminal'
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/5b/5a/1292a0df4ff71fbc00dfa8c08759d17c97e1e8ea9277eb5bc5f079ca188d/pymupdf-1.26.5-cp39-abi3-manylinux_2_28_x86_64.whl
- name: pymupdf
- version: 1.26.5
- sha256: caad0ffeb63dcc4a29ca40f3c68d7b78d32a932e834b0056b529cc0bdbaaffc9
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl
- name: pyparsing
- version: 3.2.5
- sha256: e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e
- requires_dist:
- - railroad-diagrams ; extra == 'diagrams'
- - jinja2 ; extra == 'diagrams'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/ff/49/a640b288a48dab1752281dd9b72c0679fccea107874e80a65a606b00efa9/pypdfium2-5.6.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: pypdfium2
+ version: 5.6.0
+ sha256: 515be355222cc57ae9e62cd5c7c350b8e0c863efc539f80c7d75e2811ba45cb6
+ requires_python: '>=3.6'
+- pypi: https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl
+ name: pyproject-hooks
+ version: 1.2.0
+ sha256: 9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl
name: pytest
- version: 8.4.2
- sha256: 872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79
+ version: 9.0.2
+ sha256: 711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b
requires_dist:
- colorama>=0.4 ; sys_platform == 'win32'
- exceptiongroup>=1 ; python_full_version < '3.11'
- - iniconfig>=1
- - packaging>=20
+ - iniconfig>=1.0.1
+ - packaging>=22
- pluggy>=1.5,<2
- pygments>=2.7.2
- tomli>=1 ; python_full_version < '3.11'
@@ -3350,20 +3258,20 @@ packages:
- requests ; extra == 'dev'
- setuptools ; extra == 'dev'
- xmlschema ; extra == 'dev'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl
name: pytest-asyncio
- version: 1.2.0
- sha256: 8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99
+ version: 1.3.0
+ sha256: 611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5
requires_dist:
- backports-asyncio-runner>=1.1,<2 ; python_full_version < '3.11'
- - pytest>=8.2,<9
+ - pytest>=8.2,<10
- typing-extensions>=4.12 ; python_full_version < '3.13'
- sphinx>=5.3 ; extra == 'docs'
- sphinx-rtd-theme>=1 ; extra == 'docs'
- coverage>=6.2 ; extra == 'testing'
- hypothesis>=5.7.1 ; extra == 'testing'
- requires_python: '>=3.9'
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl
name: pytest-cov
version: 7.0.0
@@ -3376,61 +3284,33 @@ packages:
- pytest-xdist ; extra == 'testing'
- virtualenv ; extra == 'testing'
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.12-hd63d673_1_cpython.conda
- build_number: 1
- sha256: 39898d24769a848c057ab861052e50bdc266310a7509efa3514b840e85a2ae98
- md5: 5c00c8cea14ee8d02941cab9121dce41
+- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.13-hd63d673_0_cpython.conda
+ sha256: a44655c1c3e1d43ed8704890a91e12afd68130414ea2c0872e154e5633a13d7e
+ md5: 7eccb41177e15cc672e1babe9056018e
depends:
- __glibc >=2.17,<3.0.a0
- bzip2 >=1.0.8,<2.0a0
- ld_impl_linux-64 >=2.36.1
- - libexpat >=2.7.1,<3.0a0
+ - libexpat >=2.7.4,<3.0a0
- libffi >=3.5.2,<3.6.0a0
- libgcc >=14
- - liblzma >=5.8.1,<6.0a0
- - libnsl >=2.0.1,<2.1.0a0
- - libsqlite >=3.50.4,<4.0a0
- - libuuid >=2.41.2,<3.0a0
- - libxcrypt >=4.4.36
- - libzlib >=1.3.1,<2.0a0
- - ncurses >=6.5,<7.0a0
- - openssl >=3.5.4,<4.0a0
- - readline >=8.2,<9.0a0
- - tk >=8.6.13,<8.7.0a0
- - tzdata
- constrains:
- - python_abi 3.12.* *_cp312
- license: Python-2.0
- purls: []
- size: 31537229
- timestamp: 1761176876216
-- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.12-hfe2f287_0_cpython.conda
- sha256: 5386d8c8230b6478ae165ff34f57d498891ac160e871629cbb4d4256e69cc542
- md5: ceada987beec823b3c702710ee073fba
- depends:
- - __glibc >=2.17,<3.0.a0
- - bzip2 >=1.0.8,<2.0a0
- - ld_impl_linux-64 >=2.36.1
- - libexpat >=2.7.1,<3.0a0
- - libffi >=3.4.6,<3.5.0a0
- - libgcc >=14
- - liblzma >=5.8.1,<6.0a0
+ - liblzma >=5.8.2,<6.0a0
- libnsl >=2.0.1,<2.1.0a0
- - libsqlite >=3.50.4,<4.0a0
- - libuuid >=2.41.2,<3.0a0
+ - libsqlite >=3.51.2,<4.0a0
+ - libuuid >=2.41.3,<3.0a0
- libxcrypt >=4.4.36
- libzlib >=1.3.1,<2.0a0
- ncurses >=6.5,<7.0a0
- - openssl >=3.5.4,<4.0a0
- - readline >=8.2,<9.0a0
+ - openssl >=3.5.5,<4.0a0
+ - readline >=8.3,<9.0a0
- tk >=8.6.13,<8.7.0a0
- tzdata
constrains:
- python_abi 3.12.* *_cp312
license: Python-2.0
purls: []
- size: 31547362
- timestamp: 1760367376467
+ size: 31608571
+ timestamp: 1772730708989
- pypi: https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl
name: python-dateutil
version: 2.9.0.post0
@@ -3438,55 +3318,90 @@ packages:
requires_dist:
- six>=1.5
requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*'
-- pypi: https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/67/0f/019d3949a40280f6193b62bc010177d4ce702d0fce424322286488569cd3/python_discovery-1.2.1-py3-none-any.whl
+ name: python-discovery
+ version: 1.2.1
+ sha256: b6a957b24c1cd79252484d3566d1b49527581d46e789aaf43181005e56201502
+ requires_dist:
+ - filelock>=3.15.4
+ - platformdirs>=4.3.6,<5
+ - furo>=2025.12.19 ; extra == 'docs'
+ - sphinx-autodoc-typehints>=3.6.3 ; extra == 'docs'
+ - sphinx>=9.1 ; extra == 'docs'
+ - sphinxcontrib-mermaid>=2 ; extra == 'docs'
+ - covdefaults>=2.3 ; extra == 'testing'
+ - coverage>=7.5.4 ; extra == 'testing'
+ - pytest-mock>=3.14 ; extra == 'testing'
+ - pytest>=8.3.5 ; extra == 'testing'
+ - setuptools>=75.1 ; extra == 'testing'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl
name: python-dotenv
- version: 1.1.1
- sha256: 31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc
+ version: 1.2.2
+ sha256: 1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a
requires_dist:
- click>=5.0 ; extra == 'cli'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl
- name: pytz
- version: '2025.2'
- sha256: 5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: pyyaml
version: 6.0.3
sha256: ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/20/14/7399c18c460e72d1b754e80dafc9f65cb42a46cc8f29cd57d11c0c4acc94/rapidfuzz-3.14.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/c4/43/80f67e0336cb2fc725f8e06f7fe35c1d0fe946f4d2b8b2175e797e07349e/qwen_vl_utils-0.0.14-py3-none-any.whl
+ name: qwen-vl-utils
+ version: 0.0.14
+ sha256: 5e28657bfd031e56bd447c5901b58ddfc3835285ed100f4c56580e0ade054e96
+ requires_dist:
+ - av
+ - packaging
+ - pillow
+ - requests
+ - decord ; extra == 'decord'
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/30/83/80d22997acd928eda7deadc19ccd15883904622396d6571e935993e0453a/rapidfuzz-3.14.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
name: rapidfuzz
- version: 3.14.1
- sha256: 26db0e815213d04234298dea0d884d92b9cb8d4ba954cab7cf67a35853128a33
+ version: 3.14.3
+ sha256: 6c5f545f454871e6af05753a0172849c82feaf0f521c5ca62ba09e1b382d6382
requires_dist:
- numpy ; extra == 'all'
requires_python: '>=3.10'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/rdma-core-60.0-hecca717_0.conda
- sha256: 5c09b833b698ecd19da14f5ff903063cf174382d6b32c86166984a93d427d681
- md5: fe7412835a65cd99eacf3afbb124c7ac
+- conda: https://conda.anaconda.org/conda-forge/linux-64/rdma-core-61.0-h192683f_0.conda
+ sha256: 8e0b7962cf8bec9a016cd91a6c6dc1f9ebc8e7e316b1d572f7b9047d0de54717
+ md5: d487d93d170e332ab39803e05912a762
depends:
- __glibc >=2.17,<3.0.a0
- libgcc >=14
- libnl >=3.11.0,<4.0a0
- libstdcxx >=14
- - libsystemd0 >=257.9
- - libudev1 >=257.9
+ - libsystemd0 >=257.10
+ - libudev1 >=257.10
license: Linux-OpenIB
license_family: BSD
purls: []
- size: 1244282
- timestamp: 1761557737114
-- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.2-h8c095d6_2.conda
- sha256: 2d6d0c026902561ed77cd646b5021aef2d4db22e57a5b0178dfc669231e06d2c
- md5: 283b96675859b20a825f8fa30f311446
+ size: 1268666
+ timestamp: 1769154883613
+- conda: https://conda.anaconda.org/conda-forge/linux-64/readline-8.3-h853b02a_0.conda
+ sha256: 12ffde5a6f958e285aa22c191ca01bbd3d6e710aa852e00618fa6ddc59149002
+ md5: d7d95fc8287ea7bf33e0e7116d2b95ec
depends:
- - libgcc >=13
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=14
- ncurses >=6.5,<7.0a0
license: GPL-3.0-only
license_family: GPL
purls: []
- size: 282480
- timestamp: 1740379431762
+ size: 345073
+ timestamp: 1765813471974
+- pypi: https://files.pythonhosted.org/packages/e1/67/921ec3024056483db83953ae8e48079ad62b92db7880013ca77632921dd0/readme_renderer-44.0-py3-none-any.whl
+ name: readme-renderer
+ version: '44.0'
+ sha256: 2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151
+ requires_dist:
+ - nh3>=0.2.14
+ - docutils>=0.21.2
+ - pygments>=2.5.1
+ - cmarkgfm>=0.8.0 ; extra == 'md'
+ requires_python: '>=3.9'
- pypi: https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl
name: referencing
version: 0.37.0
@@ -3496,16 +3411,11 @@ packages:
- rpds-py>=0.7.0
- typing-extensions>=4.4.0 ; python_full_version < '3.13'
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/35/9e/a91b50332a9750519320ed30ec378b74c996f6befe282cfa6bb6cea7e9fd/regex-2025.9.18-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
- name: regex
- version: 2025.9.18
- sha256: 4f130c3a7845ba42de42f380fff3c8aebe89a810747d91bcf56d40a069f15352
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/99/14/9a39b7c9e007968411bc3c843cc14cf15437510c0a9991f080cab654fd16/regex-2025.10.23-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/9e/40/bb226f203caa22c1043c1ca79b36340156eca0f6a6742b46c3bb222a3a57/regex-2026.2.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: regex
- version: 2025.10.23
- sha256: d97d73818c642c938db14c0668167f8d39520ca9d983604575ade3fda193afcc
- requires_python: '>=3.9'
+ version: 2026.2.28
+ sha256: d6b08a06976ff4fb0d83077022fde3eca06c55432bb997d8c0495b9a4e9872f4
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl
name: requests
version: 2.32.5
@@ -3518,53 +3428,59 @@ packages:
- pysocks>=1.5.6,!=1.5.7 ; extra == 'socks'
- chardet>=3.0.2,<6 ; extra == 'use-chardet-on-py3'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl
+ name: requests-toolbelt
+ version: 1.0.0
+ sha256: cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06
+ requires_dist:
+ - requests>=2.0.1,<3.0.0
+ requires_python: '>=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*'
+- pypi: https://files.pythonhosted.org/packages/ff/9a/9afaade874b2fa6c752c36f1548f718b5b83af81ed9b76628329dab81c1b/rfc3986-2.0.0-py2.py3-none-any.whl
+ name: rfc3986
+ version: 2.0.0
+ sha256: 50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd
+ requires_dist:
+ - idna ; extra == 'idna2008'
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl
name: rich
- version: 14.2.0
- sha256: 76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd
+ version: 13.9.4
+ sha256: 6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90
requires_dist:
- ipywidgets>=7.5.1,<9 ; extra == 'jupyter'
- markdown-it-py>=2.2.0
- pygments>=2.13.0,<3.0.0
+ - typing-extensions>=4.0.0,<5.0 ; python_full_version < '3.11'
requires_python: '>=3.8.0'
-- pypi: https://files.pythonhosted.org/packages/e2/c5/9136736c37022a6ad27fea38f3111eb8f02fe75d067f9a985cc358653102/rouge_score-0.1.2.tar.gz
- name: rouge-score
- version: 0.1.2
- sha256: c7d4da2683e68c9abf0135ef915d63a46643666f848e558a1b9f7ead17ff0f04
+- pypi: https://files.pythonhosted.org/packages/04/54/6f679c435d28e0a568d8e8a7c0a93a09010818634c3c3907fc98d8983770/roman_numerals-4.1.0-py3-none-any.whl
+ name: roman-numerals
+ version: 4.1.0
+ sha256: 647ba99caddc2cc1e55a51e4360689115551bf4476d90e8162cf8c345fe233c7
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/27/2c/daca29684cbe9fd4bc711f8246da3c10adca1ccc4d24436b17572eb2590e/roman_numerals_py-4.1.0-py3-none-any.whl
+ name: roman-numerals-py
+ version: 4.1.0
+ sha256: 553114c1167141c1283a51743759723ecd05604a1b6b507225e91dc1a6df0780
requires_dist:
- - absl-py
- - nltk
- - numpy
- - six>=1.14.0
- requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/ed/7b/8f4fee9ba1fb5ec856eb22d725a4efa3deb47f769597c809e03578b0f9d9/rpds_py-0.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ - roman-numerals==4.1.0
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: rpds-py
- version: 0.27.1
- sha256: 466bfe65bd932da36ff279ddd92de56b042f2266d752719beb97b08526268ec5
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl
- name: rsa
- version: 4.9.1
- sha256: 68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762
- requires_dist:
- - pyasn1>=0.1.3
- requires_python: '>=3.6,<4'
-- pypi: https://files.pythonhosted.org/packages/d1/75/e5d44be90525cd28503e7f836d077ae6663ec0687a13ba7810b4114b3668/rtree-1.4.1-py3-none-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl
- name: rtree
- version: 1.4.1
- sha256: 12de4578f1b3381a93a655846900be4e3d5f4cd5e306b8b00aa77c1121dc7e8c
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/75/f0/782e7d681d660eda8c536962920c41309e6dd4ebcea9a2714ed5127d44bd/ruff-0.11.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ version: 0.30.0
+ sha256: 47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: ruff
- version: 0.11.13
- sha256: 4ffbc82d70424b275b089166310448051afdc6e914fdab90e08df66c43bb5ca9
+ version: 0.15.7
+ sha256: dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/fe/5d/5a514d7b88e310c8b146e2404e0dc161282e78634d9358975fd56dfd14be/safetensors-0.6.2-cp38-abi3-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
name: safetensors
- version: 0.6.2
- sha256: 8045db2c872db8f4cbe3faa0495932d89c38c899c603f21e9b6486951a5ecb8f
+ version: 0.7.0
+ sha256: dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48
requires_dist:
- numpy>=1.21.6 ; extra == 'numpy'
+ - packaging ; extra == 'torch'
- safetensors[numpy] ; extra == 'torch'
- torch>=1.10 ; extra == 'torch'
- safetensors[numpy] ; extra == 'tensorflow'
@@ -3601,139 +3517,25 @@ packages:
- safetensors[testing] ; extra == 'all'
- safetensors[all] ; extra == 'dev'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/6b/b5/b75527c0f9532dd8a93e8e7cd8e62e547b9f207d4c11e24f0006e8646b36/scikit_image-0.25.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- name: scikit-image
- version: 0.25.2
- sha256: a17e17eb8562660cc0d31bb55643a4da996a81944b82c54805c91b3fe66f4824
+- pypi: https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl
+ name: secretstorage
+ version: 3.5.0
+ sha256: 0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137
requires_dist:
- - numpy>=1.24
- - scipy>=1.11.4
- - networkx>=3.0
- - pillow>=10.1
- - imageio>=2.33,!=2.35.0
- - tifffile>=2022.8.12
- - packaging>=21
- - lazy-loader>=0.4
- - meson-python>=0.16 ; extra == 'build'
- - ninja>=1.11.1.1 ; extra == 'build'
- - cython>=3.0.8 ; extra == 'build'
- - pythran>=0.16 ; extra == 'build'
- - numpy>=2.0 ; extra == 'build'
- - spin==0.13 ; extra == 'build'
- - build>=1.2.1 ; extra == 'build'
- - pooch>=1.6.0 ; extra == 'data'
- - pre-commit ; extra == 'developer'
- - ipython ; extra == 'developer'
- - tomli ; python_full_version < '3.11' and extra == 'developer'
- - sphinx>=8.0 ; extra == 'docs'
- - sphinx-gallery[parallel]>=0.18 ; extra == 'docs'
- - numpydoc>=1.7 ; extra == 'docs'
- - sphinx-copybutton ; extra == 'docs'
- - matplotlib>=3.7 ; extra == 'docs'
- - dask[array]>=2023.2.0 ; extra == 'docs'
- - pandas>=2.0 ; extra == 'docs'
- - seaborn>=0.11 ; extra == 'docs'
- - pooch>=1.6 ; extra == 'docs'
- - tifffile>=2022.8.12 ; extra == 'docs'
- - myst-parser ; extra == 'docs'
- - intersphinx-registry>=0.2411.14 ; extra == 'docs'
- - ipywidgets ; extra == 'docs'
- - ipykernel ; extra == 'docs'
- - plotly>=5.20 ; extra == 'docs'
- - kaleido==0.2.1 ; extra == 'docs'
- - scikit-learn>=1.2 ; extra == 'docs'
- - sphinx-design>=0.5 ; extra == 'docs'
- - pydata-sphinx-theme>=0.16 ; extra == 'docs'
- - pywavelets>=1.6 ; extra == 'docs'
- - pytest-doctestplus ; extra == 'docs'
- - simpleitk ; extra == 'optional'
- - astropy>=5.0 ; extra == 'optional'
- - cloudpickle>=1.1.1 ; extra == 'optional'
- - dask[array]>=2023.2.0 ; extra == 'optional'
- - matplotlib>=3.7 ; extra == 'optional'
- - pooch>=1.6.0 ; extra == 'optional'
- - pyamg>=5.2 ; extra == 'optional'
- - pywavelets>=1.6 ; extra == 'optional'
- - scikit-learn>=1.2 ; extra == 'optional'
- - asv ; extra == 'test'
- - numpydoc>=1.7 ; extra == 'test'
- - pooch>=1.6.0 ; extra == 'test'
- - pytest>=8 ; extra == 'test'
- - pytest-cov>=2.11.0 ; extra == 'test'
- - pytest-localserver ; extra == 'test'
- - pytest-faulthandler ; extra == 'test'
- - pytest-doctestplus ; extra == 'test'
+ - cryptography>=2.0
+ - jeepney>=0.6
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/53/11/a0160990b82999b45874dc60c0c183d3a3a969a563fffc476d5a9995c407/scipy-1.16.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- name: scipy
- version: 1.16.2
- sha256: f66bd07ba6f84cd4a380b41d1bf3c59ea488b590a2ff96744845163309ee8e2f
- requires_dist:
- - numpy>=1.25.2,<2.6
- - pytest>=8.0.0 ; extra == 'test'
- - pytest-cov ; extra == 'test'
- - pytest-timeout ; extra == 'test'
- - pytest-xdist ; extra == 'test'
- - asv ; extra == 'test'
- - mpmath ; extra == 'test'
- - gmpy2 ; extra == 'test'
- - threadpoolctl ; extra == 'test'
- - scikit-umfpack ; extra == 'test'
- - pooch ; extra == 'test'
- - hypothesis>=6.30 ; extra == 'test'
- - array-api-strict>=2.3.1 ; extra == 'test'
- - cython ; extra == 'test'
- - meson ; extra == 'test'
- - ninja ; sys_platform != 'emscripten' and extra == 'test'
- - sphinx>=5.0.0,<8.2.0 ; extra == 'doc'
- - intersphinx-registry ; extra == 'doc'
- - pydata-sphinx-theme>=0.15.2 ; extra == 'doc'
- - sphinx-copybutton ; extra == 'doc'
- - sphinx-design>=0.4.0 ; extra == 'doc'
- - matplotlib>=3.5 ; extra == 'doc'
- - numpydoc ; extra == 'doc'
- - jupytext ; extra == 'doc'
- - myst-nb>=1.2.0 ; extra == 'doc'
- - pooch ; extra == 'doc'
- - jupyterlite-sphinx>=0.19.1 ; extra == 'doc'
- - jupyterlite-pyodide-kernel ; extra == 'doc'
- - linkify-it-py ; extra == 'doc'
- - mypy==1.10.0 ; extra == 'dev'
- - typing-extensions ; extra == 'dev'
- - types-psutil ; extra == 'dev'
- - pycodestyle ; extra == 'dev'
- - ruff>=0.0.292 ; extra == 'dev'
- - cython-lint>=0.12.2 ; extra == 'dev'
- - rich-click ; extra == 'dev'
- - doit>=0.36.0 ; extra == 'dev'
- - pydevtool ; extra == 'dev'
- requires_python: '>=3.11'
-- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-80.9.0-pyhff2d567_0.conda
- sha256: 972560fcf9657058e3e1f97186cc94389144b46dbdf58c807ce62e83f977e863
- md5: 4de79c071274a53dcaf2a8c749d1499e
+- conda: https://conda.anaconda.org/conda-forge/noarch/setuptools-82.0.1-pyh332efcf_0.conda
+ sha256: 82088a6e4daa33329a30bc26dc19a98c7c1d3f05c0f73ce9845d4eab4924e9e1
+ md5: 8e194e7b992f99a5015edbd4ebd38efd
depends:
- - python >=3.9
+ - python >=3.10
license: MIT
license_family: MIT
purls:
- - pkg:pypi/setuptools?source=hash-mapping
- size: 748788
- timestamp: 1748804951958
-- pypi: https://files.pythonhosted.org/packages/b9/37/e781683abac55dde9771e086b790e554811a71ed0b2b8a1e789b7430dd44/shapely-2.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
- name: shapely
- version: 2.1.2
- sha256: 1e7d4d7ad262a48bb44277ca12c7c78cb1b0f56b32c10734ec9a1d30c0b0c54b
- requires_dist:
- - numpy>=1.21
- - pytest ; extra == 'test'
- - pytest-cov ; extra == 'test'
- - scipy-doctest ; extra == 'test'
- - numpydoc==1.1.* ; extra == 'docs'
- - matplotlib ; extra == 'docs'
- - sphinx ; extra == 'docs'
- - sphinx-book-theme ; extra == 'docs'
- - sphinx-remove-toctrees ; extra == 'docs'
- requires_python: '>=3.10'
+ - pkg:pypi/setuptools?source=compressed-mapping
+ size: 639697
+ timestamp: 1773074868565
- pypi: https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl
name: shellingham
version: 1.5.4
@@ -3749,6 +3551,189 @@ packages:
version: 1.3.1
sha256: 2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2
requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/c8/78/3565d011c61f5a43488987ee32b6f3f656e7f107ac2782dd57bdd7d91d9a/snowballstemmer-3.0.1-py3-none-any.whl
+ name: snowballstemmer
+ version: 3.0.1
+ sha256: 6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064
+ requires_python: '!=3.0.*,!=3.1.*,!=3.2.*'
+- pypi: https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl
+ name: soupsieve
+ version: 2.8.3
+ sha256: ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/31/53/136e9eca6e0b9dc0e1962e2c908fbea2e5ac000c2a2fbd9a35797958c48b/sphinx-8.2.3-py3-none-any.whl
+ name: sphinx
+ version: 8.2.3
+ sha256: 4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3
+ requires_dist:
+ - sphinxcontrib-applehelp>=1.0.7
+ - sphinxcontrib-devhelp>=1.0.6
+ - sphinxcontrib-htmlhelp>=2.0.6
+ - sphinxcontrib-jsmath>=1.0.1
+ - sphinxcontrib-qthelp>=1.0.6
+ - sphinxcontrib-serializinghtml>=1.1.9
+ - jinja2>=3.1
+ - pygments>=2.17
+ - docutils>=0.20,<0.22
+ - snowballstemmer>=2.2
+ - babel>=2.13
+ - alabaster>=0.7.14
+ - imagesize>=1.3
+ - requests>=2.30.0
+ - roman-numerals-py>=1.0.0
+ - packaging>=23.0
+ - colorama>=0.4.6 ; sys_platform == 'win32'
+ - sphinxcontrib-websupport ; extra == 'docs'
+ - ruff==0.9.9 ; extra == 'lint'
+ - mypy==1.15.0 ; extra == 'lint'
+ - sphinx-lint>=0.9 ; extra == 'lint'
+ - types-colorama==0.4.15.20240311 ; extra == 'lint'
+ - types-defusedxml==0.7.0.20240218 ; extra == 'lint'
+ - types-docutils==0.21.0.20241128 ; extra == 'lint'
+ - types-pillow==10.2.0.20240822 ; extra == 'lint'
+ - types-pygments==2.19.0.20250219 ; extra == 'lint'
+ - types-requests==2.32.0.20241016 ; extra == 'lint'
+ - types-urllib3==1.26.25.14 ; extra == 'lint'
+ - pyright==1.1.395 ; extra == 'lint'
+ - pytest>=8.0 ; extra == 'lint'
+ - pypi-attestations==0.0.21 ; extra == 'lint'
+ - betterproto==2.0.0b6 ; extra == 'lint'
+ - pytest>=8.0 ; extra == 'test'
+ - pytest-xdist[psutil]>=3.4 ; extra == 'test'
+ - defusedxml>=0.7.1 ; extra == 'test'
+ - cython>=3.0 ; extra == 'test'
+ - setuptools>=70.0 ; extra == 'test'
+ - typing-extensions>=4.9 ; extra == 'test'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/d7/20/56411b52f917696995f5ad27d2ea7e9492c84a043c5b49a3a3173573cd93/sphinx_autobuild-2025.8.25-py3-none-any.whl
+ name: sphinx-autobuild
+ version: 2025.8.25
+ sha256: b750ac7d5a18603e4665294323fd20f6dcc0a984117026d1986704fa68f0379a
+ requires_dist:
+ - colorama>=0.4.6
+ - sphinx
+ - starlette>=0.35
+ - uvicorn>=0.25
+ - watchfiles>=0.20
+ - websockets>=11
+ - httpx ; extra == 'test'
+ - pytest>=6 ; extra == 'test'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/9e/48/1ea60e74949eecb12cdd6ac43987f9fd331156388dcc2319b45e2ebb81bf/sphinx_copybutton-0.5.2-py3-none-any.whl
+ name: sphinx-copybutton
+ version: 0.5.2
+ sha256: fb543fd386d917746c9a2c50360c7905b605726b9355cd26e9974857afeae06e
+ requires_dist:
+ - sphinx>=1.8
+ - pre-commit==2.12.1 ; extra == 'code-style'
+ - sphinx ; extra == 'rtd'
+ - ipython ; extra == 'rtd'
+ - myst-nb ; extra == 'rtd'
+ - sphinx-book-theme ; extra == 'rtd'
+ - sphinx-examples ; extra == 'rtd'
+ requires_python: '>=3.7'
+- pypi: https://files.pythonhosted.org/packages/30/cf/45dd359f6ca0c3762ce0490f681da242f0530c49c81050c035c016bfdd3a/sphinx_design-0.7.0-py3-none-any.whl
+ name: sphinx-design
+ version: 0.7.0
+ sha256: f82bf179951d58f55dca78ab3706aeafa496b741a91b1911d371441127d64282
+ requires_dist:
+ - sphinx>=7,<10
+ - pre-commit>=3,<4 ; extra == 'code-style'
+ - myst-parser>=4,<6 ; extra == 'rtd'
+ - myst-parser>=4,<6 ; extra == 'testing'
+ - pytest~=8.3 ; extra == 'testing'
+ - pytest-cov ; extra == 'testing'
+ - pytest-regressions ; extra == 'testing'
+ - defusedxml ; extra == 'testing'
+ - pytest~=8.3 ; extra == 'testing-no-myst'
+ - pytest-cov ; extra == 'testing-no-myst'
+ - pytest-regressions ; extra == 'testing-no-myst'
+ - defusedxml ; extra == 'testing-no-myst'
+ - furo~=2024.7.18 ; extra == 'theme-furo'
+ - sphinx-immaterial~=0.12.2 ; extra == 'theme-im'
+ - pydata-sphinx-theme~=0.15.2 ; extra == 'theme-pydata'
+ - sphinx-rtd-theme~=2.0 ; extra == 'theme-rtd'
+ - sphinx-book-theme~=1.1 ; extra == 'theme-sbt'
+ requires_python: '>=3.11'
+- pypi: https://files.pythonhosted.org/packages/5d/85/9ebeae2f76e9e77b952f4b274c27238156eae7979c5421fba91a28f4970d/sphinxcontrib_applehelp-2.0.0-py3-none-any.whl
+ name: sphinxcontrib-applehelp
+ version: 2.0.0
+ sha256: 4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5
+ requires_dist:
+ - ruff==0.5.5 ; extra == 'lint'
+ - mypy ; extra == 'lint'
+ - types-docutils ; extra == 'lint'
+ - sphinx>=5 ; extra == 'standalone'
+ - pytest ; extra == 'test'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/35/7a/987e583882f985fe4d7323774889ec58049171828b58c2217e7f79cdf44e/sphinxcontrib_devhelp-2.0.0-py3-none-any.whl
+ name: sphinxcontrib-devhelp
+ version: 2.0.0
+ sha256: aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2
+ requires_dist:
+ - ruff==0.5.5 ; extra == 'lint'
+ - mypy ; extra == 'lint'
+ - types-docutils ; extra == 'lint'
+ - sphinx>=5 ; extra == 'standalone'
+ - pytest ; extra == 'test'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/0a/7b/18a8c0bcec9182c05a0b3ec2a776bba4ead82750a55ff798e8d406dae604/sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl
+ name: sphinxcontrib-htmlhelp
+ version: 2.1.0
+ sha256: 166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8
+ requires_dist:
+ - ruff==0.5.5 ; extra == 'lint'
+ - mypy ; extra == 'lint'
+ - types-docutils ; extra == 'lint'
+ - sphinx>=5 ; extra == 'standalone'
+ - pytest ; extra == 'test'
+ - html5lib ; extra == 'test'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/c2/42/4c8646762ee83602e3fb3fbe774c2fac12f317deb0b5dbeeedd2d3ba4b77/sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl
+ name: sphinxcontrib-jsmath
+ version: 1.0.1
+ sha256: 2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178
+ requires_dist:
+ - pytest ; extra == 'test'
+ - flake8 ; extra == 'test'
+ - mypy ; extra == 'test'
+ requires_python: '>=3.5'
+- pypi: https://files.pythonhosted.org/packages/27/83/859ecdd180cacc13b1f7e857abf8582a64552ea7a061057a6c716e790fce/sphinxcontrib_qthelp-2.0.0-py3-none-any.whl
+ name: sphinxcontrib-qthelp
+ version: 2.0.0
+ sha256: b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb
+ requires_dist:
+ - ruff==0.5.5 ; extra == 'lint'
+ - mypy ; extra == 'lint'
+ - types-docutils ; extra == 'lint'
+ - sphinx>=5 ; extra == 'standalone'
+ - pytest ; extra == 'test'
+ - defusedxml>=0.7.1 ; extra == 'test'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/52/a7/d2782e4e3f77c8450f727ba74a8f12756d5ba823d81b941f1b04da9d033a/sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl
+ name: sphinxcontrib-serializinghtml
+ version: 2.0.0
+ sha256: 6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331
+ requires_dist:
+ - ruff==0.5.5 ; extra == 'lint'
+ - mypy ; extra == 'lint'
+ - types-docutils ; extra == 'lint'
+ - sphinx>=5 ; extra == 'standalone'
+ - pytest ; extra == 'test'
+ requires_python: '>=3.9'
+- pypi: https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl
+ name: starlette
+ version: 1.0.0
+ sha256: d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b
+ requires_dist:
+ - anyio>=3.6.2,<5
+ - typing-extensions>=4.10.0 ; python_full_version < '3.13'
+ - httpx>=0.27.0,<0.29.0 ; extra == 'full'
+ - itsdangerous ; extra == 'full'
+ - jinja2 ; extra == 'full'
+ - python-multipart>=0.0.18 ; extra == 'full'
+ - pyyaml ; extra == 'full'
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl
name: sympy
version: 1.14.0
@@ -3758,55 +3743,6 @@ packages:
- pytest>=7.1.0 ; extra == 'dev'
- hypothesis>=6.70.0 ; extra == 'dev'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl
- name: tenacity
- version: 9.1.2
- sha256: f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138
- requires_dist:
- - reno ; extra == 'doc'
- - sphinx ; extra == 'doc'
- - pytest ; extra == 'test'
- - tornado>=4.5 ; extra == 'test'
- - typeguard ; extra == 'test'
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/6e/ff/e2f8dae90fb642b4b6f24464a2f96a3dc3b69151c51f7db24433be0f3f56/tifffile-2025.10.4-py3-none-any.whl
- name: tifffile
- version: 2025.10.4
- sha256: 7687d691e49026053181470cec70fa9250e3a586b2041041297e38b10bbd34e1
- requires_dist:
- - numpy
- - imagecodecs>=2024.12.30 ; extra == 'codecs'
- - defusedxml ; extra == 'xml'
- - lxml ; extra == 'xml'
- - zarr>=3.1.3 ; extra == 'zarr'
- - fsspec ; extra == 'zarr'
- - kerchunk ; extra == 'zarr'
- - matplotlib ; extra == 'plot'
- - imagecodecs>=2024.12.30 ; extra == 'all'
- - matplotlib ; extra == 'all'
- - defusedxml ; extra == 'all'
- - lxml ; extra == 'all'
- - zarr>=3.1.3 ; extra == 'all'
- - fsspec ; extra == 'all'
- - kerchunk ; extra == 'all'
- - cmapfile ; extra == 'test'
- - czifile ; extra == 'test'
- - dask ; extra == 'test'
- - defusedxml ; extra == 'test'
- - fsspec ; extra == 'test'
- - imagecodecs ; extra == 'test'
- - kerchunk ; extra == 'test'
- - lfdfiles ; extra == 'test'
- - lxml ; extra == 'test'
- - ndtiff ; extra == 'test'
- - oiffile ; extra == 'test'
- - psdtags ; extra == 'test'
- - pytest ; extra == 'test'
- - requests ; extra == 'test'
- - roifile ; extra == 'test'
- - xarray ; extra == 'test'
- - zarr>=3.1.3 ; extra == 'test'
- requires_python: '>=3.11'
- pypi: https://files.pythonhosted.org/packages/f4/90/3dae6cc5436137ebd38944d396b5849e167896fc2073da643a49f372dc4f/tiktoken-0.12.0-cp312-cp312-manylinux_2_28_x86_64.whl
name: tiktoken
version: 0.12.0
@@ -3816,22 +3752,24 @@ packages:
- requests>=2.26.0
- blobfile>=2 ; extra == 'blobfile'
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_hd72426e_102.conda
- sha256: a84ff687119e6d8752346d1d408d5cf360dee0badd487a472aa8ddedfdc219e1
- md5: a0116df4f4ed05c303811a837d5b39d8
+- conda: https://conda.anaconda.org/conda-forge/linux-64/tk-8.6.13-noxft_h366c992_103.conda
+ sha256: cafeec44494f842ffeca27e9c8b0c27ed714f93ac77ddadc6aaf726b5554ebac
+ md5: cffd3bdd58090148f4cfcd831f4b26ab
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
+ - libgcc >=14
- libzlib >=1.3.1,<2.0a0
+ constrains:
+ - xorg-libx11 >=1.8.12,<2.0a0
license: TCL
license_family: BSD
purls: []
- size: 3285204
- timestamp: 1748387766691
-- pypi: https://files.pythonhosted.org/packages/d0/c6/dc3a0db5a6766416c32c034286d7c2d406da1f498e4de04ab1b8959edd00/tokenizers-0.22.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ size: 3301196
+ timestamp: 1769460227866
+- pypi: https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
name: tokenizers
- version: 0.22.1
- sha256: e2ef6063d7a84994129732b47e7915e8710f27f99f3a3260b8a38fc7ccd083f4
+ version: 0.22.2
+ sha256: 369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67
requires_dist:
- huggingface-hub>=0.16.4,<2.0
- pytest ; extra == 'testing'
@@ -3839,17 +3777,17 @@ packages:
- requests ; extra == 'testing'
- numpy ; extra == 'testing'
- datasets ; extra == 'testing'
- - black==22.3 ; extra == 'testing'
- ruff ; extra == 'testing'
+ - ty ; extra == 'testing'
- sphinx ; extra == 'docs'
- sphinx-rtd-theme ; extra == 'docs'
- setuptools-rust ; extra == 'docs'
- tokenizers[testing] ; extra == 'dev'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/a5/4b/f4bb2e6c25d0272f798cd6d7a04ed315da76cec68c602d87040c7847287f/torch-2.9.0-cp312-cp312-manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl
name: torch
- version: 2.9.0
- sha256: 01cff95ecd9a212ea2f141db28acccdceb6a4c54f64e6c51091146f5e2a772c6
+ version: 2.10.0
+ sha256: 98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6
requires_dist:
- filelock
- typing-extensions>=4.10.0
@@ -3858,6 +3796,7 @@ packages:
- networkx>=2.5.1
- jinja2
- fsspec>=0.8.5
+ - cuda-bindings==12.9.4 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-cuda-nvrtc-cu12==12.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-cuda-runtime-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-cuda-cupti-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux'
@@ -3869,32 +3808,33 @@ packages:
- nvidia-cusparse-cu12==12.5.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-cusparselt-cu12==0.7.1 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-nccl-cu12==2.27.5 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- - nvidia-nvshmem-cu12==3.3.20 ; platform_machine == 'x86_64' and sys_platform == 'linux'
+ - nvidia-nvshmem-cu12==3.4.5 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-nvtx-cu12==12.8.90 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-nvjitlink-cu12==12.8.93 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- nvidia-cufile-cu12==1.13.1.3 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- - triton==3.5.0 ; platform_machine == 'x86_64' and sys_platform == 'linux'
+ - triton==3.6.0 ; platform_machine == 'x86_64' and sys_platform == 'linux'
- optree>=0.13.0 ; extra == 'optree'
- opt-einsum>=3.3 ; extra == 'opt-einsum'
- pyyaml ; extra == 'pyyaml'
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/7e/e6/7324ead6793075a8c75c56abeed1236d1750de16a5613cfe2ddad164a92a/torchvision-0.24.0-cp312-cp312-manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/68/2f/f24b039169db474e8688f649377de082a965fbf85daf4e46c44412f1d15a/torchvision-0.25.0-cp312-cp312-manylinux_2_28_x86_64.whl
name: torchvision
- version: 0.24.0
- sha256: 26b9dd9c083f8e5f7ac827de6d5b88c615d9c582dc87666770fbdf16887e4c25
+ version: 0.25.0
+ sha256: f25aa9e380865b11ea6e9d99d84df86b9cc959f1a007cd966fc6f1ab2ed0e248
requires_dist:
- numpy
- - torch==2.9.0
+ - torch==2.10.0
- pillow>=5.3.0,!=8.3.*
- gdown>=4.7.3 ; extra == 'gdown'
- scipy ; extra == 'scipy'
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl
name: tqdm
- version: 4.67.1
- sha256: 26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2
+ version: 4.67.3
+ sha256: ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf
requires_dist:
- colorama ; sys_platform == 'win32'
+ - importlib-metadata ; python_full_version < '3.8'
- pytest>=6 ; extra == 'dev'
- pytest-cov ; extra == 'dev'
- pytest-timeout ; extra == 'dev'
@@ -3905,10 +3845,10 @@ packages:
- requests ; extra == 'telegram'
- ipywidgets>=6 ; extra == 'notebook'
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/71/d3/c16c3b3cf7655a67db1144da94b021c200ac1303f82428f2beef6c2e72bb/transformers-4.57.1-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/03/b8/e484ef633af3887baeeb4b6ad12743363af7cce68ae51e938e00aaa0529d/transformers-4.57.6-py3-none-any.whl
name: transformers
- version: 4.57.1
- sha256: b10d05da8fa67dc41644dbbf9bc45a44cb86ae33da6f9295f5fbf5b7890bd267
+ version: 4.57.6
+ sha256: 4c9e9de11333ddfe5114bc872c9f370509198acf0b87a832a0ab9458e2bd0550
requires_dist:
- filelock
- huggingface-hub>=0.34.0,<1.0
@@ -4368,10 +4308,10 @@ packages:
- opentelemetry-exporter-otlp ; extra == 'open-telemetry'
- opentelemetry-sdk ; extra == 'open-telemetry'
requires_python: '>=3.9.0'
-- pypi: https://files.pythonhosted.org/packages/f5/3a/e991574f3102147b642e49637e0281e9bb7c4ba254edb2bab78247c85e01/triton-3.5.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
name: triton
- version: 3.5.0
- sha256: c9e71db82261c4ffa3921cd050cd5faa18322d2d405c30eb56084afaff3b0833
+ version: 3.6.0
+ sha256: 74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca
requires_dist:
- importlib-metadata ; python_full_version < '3.10'
- cmake>=3.20,<4.0 ; extra == 'build'
@@ -4388,16 +4328,38 @@ packages:
- pandas ; extra == 'tutorials'
- tabulate ; extra == 'tutorials'
requires_python: '>=3.10,<3.15'
-- pypi: https://files.pythonhosted.org/packages/00/22/35617eee79080a5d071d0f14ad698d325ee6b3bf824fc0467c03b30e7fa8/typer-0.19.2-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl
+ name: twine
+ version: 6.2.0
+ sha256: 418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8
+ requires_dist:
+ - readme-renderer>=35.0
+ - requests>=2.20
+ - requests-toolbelt>=0.8.0,!=0.9.0
+ - urllib3>=1.26.0
+ - importlib-metadata>=3.6 ; python_full_version < '3.10'
+ - keyring>=21.2.0 ; platform_machine != 'ppc64le' and platform_machine != 's390x'
+ - rfc3986>=1.4.0
+ - rich>=12.0.0
+ - packaging>=24.0
+ - 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
+ name: ty
+ version: 0.0.28
+ sha256: f2849d6d212af78175430e8cc51a962a53851458182eb44a981b0e3981163177
+ requires_python: '>=3.8'
+- pypi: https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl
name: typer
- version: 0.19.2
- sha256: 755e7e19670ffad8283db353267cb81ef252f595aa6834a0d1ca9312d9326cb9
+ version: 0.24.1
+ sha256: 112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e
requires_dist:
- - click>=8.0.0
- - typing-extensions>=3.7.4.3
+ - click>=8.2.1
- shellingham>=1.3.0
- - rich>=10.11.0
- requires_python: '>=3.8'
+ - rich>=12.3.0
+ - annotated-doc>=0.0.2
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl
name: typing-extensions
version: 4.15.0
@@ -4410,113 +4372,91 @@ packages:
requires_dist:
- typing-extensions>=4.12.0
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl
- name: tzdata
- version: '2025.2'
- sha256: 1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8
- requires_python: '>=2'
-- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025b-h78e105d_0.conda
- sha256: 5aaa366385d716557e365f0a4e9c3fca43ba196872abbbe3d56bb610d131e192
- md5: 4222072737ccff51314b5ece9c7d6f5a
+- conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2025c-hc9c84f9_1.conda
+ sha256: 1d30098909076af33a35017eed6f2953af1c769e273a0626a04722ac4acaba3c
+ md5: ad659d0a2b3e47e38d829aa8cad2d610
license: LicenseRef-Public-Domain
purls: []
- size: 122968
- timestamp: 1742727099393
-- pypi: https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl
- name: uritemplate
- version: 4.2.0
- sha256: 962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686
- requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl
+ size: 119135
+ timestamp: 1767016325805
+- pypi: https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl
name: urllib3
- version: 2.5.0
- sha256: e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc
+ version: 2.6.3
+ sha256: bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4
requires_dist:
- - brotli>=1.0.9 ; platform_python_implementation == 'CPython' and extra == 'brotli'
- - brotlicffi>=0.8.0 ; platform_python_implementation != 'CPython' and extra == 'brotli'
+ - brotli>=1.2.0 ; platform_python_implementation == 'CPython' and extra == 'brotli'
+ - brotlicffi>=1.2.0.0 ; platform_python_implementation != 'CPython' and extra == 'brotli'
- h2>=4,<5 ; extra == 'h2'
- pysocks>=1.5.6,!=1.5.7,<2.0 ; extra == 'socks'
- - zstandard>=0.18.0 ; extra == 'zstd'
+ - backports-zstd>=1.0.0 ; python_full_version < '3.14' and extra == 'zstd'
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/27/73/d9a94da0e9d470a543c1b9d3ccbceb0f59455983088e727b8a1824ed90fb/virtualenv-20.35.3-py3-none-any.whl
+- pypi: https://files.pythonhosted.org/packages/55/df/0cf5b0c451602748fdc7a702d4667f6e209bf96aa6e3160d754234445f2a/uvicorn-0.43.0-py3-none-any.whl
+ name: uvicorn
+ version: 0.43.0
+ sha256: 46fac64f487fd968cd999e5e49efbbe64bd231b5bd8b4a0b482a23ebce499620
+ requires_dist:
+ - click>=7.0
+ - h11>=0.8
+ - typing-extensions>=4.0 ; python_full_version < '3.11'
+ - colorama>=0.4 ; sys_platform == 'win32' and extra == 'standard'
+ - httptools>=0.6.3 ; extra == 'standard'
+ - python-dotenv>=0.13 ; extra == 'standard'
+ - pyyaml>=5.1 ; extra == 'standard'
+ - uvloop>=0.15.1 ; platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32' and extra == 'standard'
+ - watchfiles>=0.20 ; extra == 'standard'
+ - websockets>=10.4 ; extra == 'standard'
+ requires_python: '>=3.10'
+- pypi: https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl
name: virtualenv
- version: 20.35.3
- sha256: 63d106565078d8c8d0b206d48080f938a8b25361e19432d2c9db40d2899c810a
+ version: 21.2.0
+ sha256: 1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f
requires_dist:
- distlib>=0.3.7,<1
- - filelock>=3.12.2,<4
+ - filelock>=3.24.2,<4 ; python_full_version >= '3.10'
+ - filelock>=3.16.1,<=3.19.1 ; python_full_version < '3.10'
- importlib-metadata>=6.6 ; python_full_version < '3.8'
- platformdirs>=3.9.1,<5
+ - python-discovery>=1
- typing-extensions>=4.13.2 ; python_full_version < '3.11'
- - furo>=2023.7.26 ; extra == 'docs'
- - proselint>=0.13 ; extra == 'docs'
- - sphinx>=7.1.2,!=7.3 ; extra == 'docs'
- - sphinx-argparse>=0.4 ; extra == 'docs'
- - sphinxcontrib-towncrier>=0.2.1a0 ; extra == 'docs'
- - towncrier>=23.6 ; extra == 'docs'
- - covdefaults>=2.3 ; extra == 'test'
- - coverage-enable-subprocess>=1 ; extra == 'test'
- - coverage>=7.2.7 ; extra == 'test'
- - flaky>=3.7 ; extra == 'test'
- - packaging>=23.1 ; extra == 'test'
- - pytest-env>=0.8.2 ; extra == 'test'
- - pytest-freezer>=0.4.8 ; (python_full_version >= '3.13' and platform_python_implementation == 'CPython' and sys_platform == 'win32' and extra == 'test') or (platform_python_implementation == 'GraalVM' and extra == 'test') or (platform_python_implementation == 'PyPy' and extra == 'test')
- - pytest-mock>=3.11.1 ; extra == 'test'
- - pytest-randomly>=3.12 ; extra == 'test'
- - pytest-timeout>=2.1 ; extra == 'test'
- - pytest>=7.4 ; extra == 'test'
- - setuptools>=68 ; extra == 'test'
- - time-machine>=2.10 ; platform_python_implementation == 'CPython' and extra == 'test'
requires_python: '>=3.8'
-- pypi: https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl
- name: websockets
- version: 15.0.1
- sha256: 64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65
+- pypi: https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
+ name: watchfiles
+ version: 1.1.1
+ sha256: 1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803
+ requires_dist:
+ - anyio>=3.0.0
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.45.1-pyhd8ed1ab_1.conda
- sha256: 1b34021e815ff89a4d902d879c3bd2040bc1bd6169b32e9427497fa05c55f1ce
- md5: 75cb7132eb58d97896e173ef12ac9986
+- pypi: https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl
+ name: websockets
+ version: '16.0'
+ sha256: 9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c
+ requires_python: '>=3.10'
+- conda: https://conda.anaconda.org/conda-forge/noarch/wheel-0.46.3-pyhd8ed1ab_0.conda
+ sha256: d6cf2f0ebd5e09120c28ecba450556ce553752652d91795442f0e70f837126ae
+ md5: bdbd7385b4a67025ac2dba4ef8cb6a8f
depends:
- - python >=3.9
+ - packaging >=24.0
+ - python >=3.10
license: MIT
license_family: MIT
purls:
- pkg:pypi/wheel?source=hash-mapping
- size: 62931
- timestamp: 1733130309598
-- pypi: https://files.pythonhosted.org/packages/dc/1a/81ac398d35be848f3655893b6260f227b29ca6acf5c2674dc5ed9af63027/xmlschema-4.2.0-py3-none-any.whl
- name: xmlschema
- version: 4.2.0
- sha256: 82d24a50eea5e7f2d603312813848cd66fddf8fa2b6730839c6aa3d66312e3b6
- requires_dist:
- - elementpath>=5.0.1,<6.0.0
- - jinja2 ; extra == 'codegen'
- - coverage ; extra == 'dev'
- - flake8 ; extra == 'dev'
- - lxml ; extra == 'dev'
- - lxml-stubs ; extra == 'dev'
- - mypy ; extra == 'dev'
- - psutil ; extra == 'dev'
- - tox ; extra == 'dev'
- - xmlschema[docs] ; extra == 'dev'
- - jinja2 ; extra == 'docs'
- - sphinx ; extra == 'docs'
- - sphinx-rtd-theme ; extra == 'docs'
- requires_python: '>=3.9'
+ size: 31858
+ timestamp: 1769139207397
- pypi: https://files.pythonhosted.org/packages/11/4f/426f91b96701ec2f37bb2b8cec664eff4f658a11f3fa9d94f0a887ea6d2b/xxhash-3.6.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: xxhash
version: 3.6.0
sha256: 49e03e6fe2cac4a1bc64952dd250cf0dbc5ef4ebb7b8d96bce82e2de163c82a2
requires_python: '>=3.7'
-- pypi: https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
+- pypi: https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl
name: yarl
- version: 1.22.0
- sha256: 50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a
+ version: 1.23.0
+ sha256: a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51
requires_dist:
- idna>=2.0
- multidict>=4.0
- propcache>=0.2.1
- requires_python: '>=3.9'
+ requires_python: '>=3.10'
- pypi: https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl
name: zipp
version: 3.23.0
@@ -4541,16 +4481,14 @@ packages:
- pytest-enabler>=2.2 ; extra == 'enabler'
- pytest-mypy ; extra == 'type'
requires_python: '>=3.9'
-- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb8e6e7a_2.conda
- sha256: a4166e3d8ff4e35932510aaff7aa90772f84b4d07e9f6f83c614cba7ceefe0eb
- md5: 6432cb5d4ac0046c3ac0a8a0f95842f9
+- conda: https://conda.anaconda.org/conda-forge/linux-64/zstd-1.5.7-hb78ec9c_6.conda
+ sha256: 68f0206ca6e98fea941e5717cec780ed2873ffabc0e1ed34428c061e2c6268c7
+ md5: 4a13eeac0b5c8e5b8ab496e6c4ddd829
depends:
- __glibc >=2.17,<3.0.a0
- - libgcc >=13
- - libstdcxx >=13
- libzlib >=1.3.1,<2.0a0
license: BSD-3-Clause
license_family: BSD
purls: []
- size: 567578
- timestamp: 1742433379869
+ size: 601375
+ timestamp: 1764777111296
diff --git a/pixi.toml b/pixi.toml
deleted file mode 100644
index e20dcf7..0000000
--- a/pixi.toml
+++ /dev/null
@@ -1,92 +0,0 @@
-#############################################
-# Churro pixi manifest
-#############################################
-
-[workspace]
- # Use conda-forge as primary channel; Linux x64 only
- channels = ["conda-forge", "main", "r", "msys2"]
- platforms = ["linux-64"]
-
-[tasks]
- # Custom task definitions (currently empty; add build/run tasks as needed)
-
-[dependencies]
- # Base conda packages shared by all environments
- python = "3.12.*"
- pip = "*"
-
-[feature.full.dependencies]
- cudnn = "9.*"
- cuda-libraries = "12.*"
-
-[feature.full.pypi-dependencies]
- # Local package for iterative development
- churro = { path = ".", editable = true }
-
- # Development tools
- ruff = ">=0.11.13,<0.12" # Linting and formatting (see ruff.toml)
- pre-commit = "*" # Git hooks for code quality
- pytest = "*" # Testing framework
- pytest-asyncio = "*" # Async test support
- coverage = ">=7.11.0, <8" # Code coverage measurement
- pytest-cov = ">=7.0.0, <8" # Coverage plugin for pytest
-
- # Utilities
- tqdm = "*" # Progress bars
- tenacity = "*" # Retry logic for API calls
- diskcache = "*" # Local caching for API responses
- rich = "*" # Beautiful terminal outputs
- loguru = "*" # Logging (used in utils.log_utils)
- python-dotenv = ">=1.1.0, <2" # Environment variable loading
-
- # Geometric/spatial operations
- rtree = "*" # Spatial indexing for bounding boxes
- shapely = "*" # Polygon operations for page elements
-
- # PDF and image processing
- pymupdf = "*" # PDF rasterization (utils/pdf/runner.py)
- Pillow = "*" # Image manipulation
- scikit-image = ">=0.25.2, <0.26" # Advanced image processing
-
- # OCR provider SDKs
- azure-ai-documentintelligence = "==1.0.2" # Azure Document Intelligence OCR
- google-cloud-documentai = "==3.6.0" # Google Document AI OCR
- mistralai = ">=1.6.0, <2" # Mistral OCR system
- google-api-python-client = ">=2.161.0, <3" # Google Cloud APIs
- google-cloud-aiplatform = ">=1.85.0, <2" # Vertex AI (Gemini models)
- anthropic = ">=0.49.0, <0.50" # Claude models
- litellm = "==1.78.0" # Unified LLM API (pinned for stability)
- aiofiles = ">=24.1.0, <25" # Async file operations
-
- # Evaluation and metrics
- evaluate = "*" # HuggingFace evaluate library
- nltk = ">=3.9.1, <4" # NLP preprocessing for metrics
- rouge-score = "*" # ROUGE metric computation
- rapidfuzz = ">=3.12.1, <4" # Fast string processing for Levenshtein similarity metric
- pyarabic = ">=0.6.15, <0.7" # Arabic text normalization
-
- # Data loading
- datasets = "*" # HuggingFace datasets (Churro dataset)
-
- # XML validation and parsing
- xmlschema = ">=4.0.1, <5" # Schema validation (historical_doc.xsd)
- lxml = "*" # Fast XML processing
-
- # Container management, for vLLM orchestration
- docker = "*" # Docker SDK for vLLM container orchestration
-
- # CLI utilities
- typer = ">=0.19.2, <0.20" # CLI utilities (used in churro/cli.py)
-
- onnxruntime-gpu = ">=1.23.1, <2" # CUDA-enabled ONNX Runtime build. Used for ImageBinarizer model inference.
-
-
-[feature.minimal.pypi-dependencies]
- # Minimal dependencies to run inference with transformers instead of vLLM
- transformers = { version = ">=4.57.0, <5", extras = ["torch"] }
- Pillow = "*"
- torchvision = "*"
-
-[environments]
- default = ["full"]
- minimal = ["minimal"]
diff --git a/pyproject.toml b/pyproject.toml
index adf18bd..22dbff8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,15 +1,153 @@
[build-system]
-requires = ["setuptools>=61"]
+requires = ["setuptools>=69"]
build-backend = "setuptools.build_meta"
[project]
-name = "churro"
+name = "churro-ocr"
version = "0.1.0"
-description = "Historical OCR tooling"
+description = "Library-first OCR and layout detection for historical documents"
+readme = { file = "docs/pypi.md", content-type = "text/markdown" }
requires-python = ">=3.12"
-dependencies = []
+license = "Apache-2.0"
+authors = [{ name = "History Genie" }]
+keywords = ["ocr", "layout-detection", "historical-documents", "llm"]
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.12",
+ "Typing :: Typed",
+]
+dependencies = [
+ "loguru>=0.7.2,<1",
+ "Pillow>=10.4.0,<12",
+ "rich>=13.9.2,<14",
+ "typer>=0.12.3,<1",
+]
-[tool.setuptools]
-packages = ["churro"]
-package-dir = { "churro" = "." }
-include-package-data = true
\ No newline at end of file
+[project.optional-dependencies]
+llm = [
+ "google-auth>=2.41.1,<3",
+ "litellm[caching]==1.82.3",
+]
+azure = [
+ "azure-ai-documentintelligence==1.0.2",
+]
+hf = [
+ "qwen-vl-utils",
+ "transformers[torch]>=4.57.0,<5",
+ "torchvision",
+]
+vllm = [
+ "transformers>=4.57.0,<5",
+ "torchvision",
+ "vllm>=0.18,<1",
+]
+mistral = [
+ "mistralai>=1.6.0,<2",
+]
+local = [
+ "litellm[caching]==1.82.3",
+]
+pdf = [
+ "pypdfium2>=5,<6",
+]
+all = [
+ "google-auth>=2.41.1,<3",
+ "litellm[caching]==1.82.3",
+ "azure-ai-documentintelligence==1.0.2",
+ "qwen-vl-utils",
+ "transformers[torch]>=4.57.0,<5",
+ "torchvision",
+ "vllm>=0.18,<1",
+ "mistralai>=1.6.0,<2",
+ "pypdfium2>=5,<6",
+]
+
+[dependency-groups]
+dev-docs = [
+ "myst-parser>=4.0.1,<5",
+ "pydata-sphinx-theme>=0.16.1,<1",
+ "sphinx>=8.2.3,<9",
+ "sphinx-autobuild>=2024.10.3,<2026",
+ "sphinx-copybutton>=0.5.2,<1",
+ "sphinx-design>=0.6.1,<1",
+]
+dev-eval = [
+ "datasets>=4.4.1,<5",
+ "evaluate>=0.4.6,<0.5",
+ "google-auth>=2.41.1,<3",
+ "nltk>=3.9.2,<4",
+ "pyarabic>=0.6.15,<0.7",
+ "rapidfuzz>=3.14.3,<4",
+ "tqdm>=4.67.1,<5",
+]
+dev-test = [
+ "coverage>=7.11.0,<8",
+ "pytest",
+ "pytest-asyncio",
+ "pytest-cov>=7.0.0,<8",
+]
+dev-tooling = [
+ "build",
+ "pre-commit>=4,<5",
+ "ruff",
+ "twine",
+ "ty>=0.0.28,<0.0.29",
+]
+dev = [
+ { include-group = "dev-docs" },
+ { include-group = "dev-eval" },
+ { include-group = "dev-test" },
+ { include-group = "dev-tooling" },
+]
+
+[project.scripts]
+churro-ocr = "churro_ocr.cli:main"
+
+[project.urls]
+Homepage = "https://github.com/stanford-oval/Churro"
+Documentation = "https://stanford-oval.github.io/Churro/"
+Repository = "https://github.com/stanford-oval/Churro"
+Issues = "https://github.com/stanford-oval/Churro/issues"
+
+[tool.setuptools.package-dir]
+"" = "src"
+
+[tool.setuptools.packages.find]
+where = ["src"]
+include = ["churro_ocr*"]
+
+[tool.setuptools.package-data]
+"churro_ocr" = ["py.typed"]
+
+[tool.pixi.workspace]
+channels = ["conda-forge", "main", "r", "msys2"]
+name = "churro-ocr"
+platforms = ["linux-64"]
+
+[tool.pixi.dependencies]
+pip = "*"
+
+[tool.pixi.feature.repo-default.pypi-dependencies]
+"churro-ocr" = { path = ".", editable = true, extras = ["llm", "azure", "hf", "mistral", "pdf"] }
+
+[tool.pixi.feature.repo-minimal.pypi-dependencies]
+"churro-ocr" = { path = ".", editable = true, extras = ["hf"] }
+
+[tool.pixi.feature.cuda.dependencies]
+cudnn = "9.*"
+cuda-libraries = "12.*"
+
+[tool.pixi.environments]
+default = { features = ["repo-default", "dev"], solve-group = "default" }
+minimal = { features = ["dev", "repo-minimal"], solve-group = "default" }
+cuda = { features = ["repo-default", "cuda", "dev"], solve-group = "default" }
+
+[tool.pixi.tasks]
+format = "ruff format src tests"
+lint = "ruff check src tests"
+typecheck = "ty check src tests"
+test = "python -m pytest"
+coverage = "python -m pytest --cov=src/churro_ocr --cov=tooling --cov-report=term-missing"
+docs-build = "python -m sphinx -W --keep-going -b html docs docs/_build/html"
+docs-serve = "python -m sphinx_autobuild --port 0 docs docs/_build/html"
+package-check = "python scripts/package_check.py"
diff --git a/pytest.ini b/pytest.ini
new file mode 100644
index 0000000..dd3f526
--- /dev/null
+++ b/pytest.ini
@@ -0,0 +1,5 @@
+[pytest]
+testpaths = tests
+asyncio_mode = auto
+markers =
+ integration: live provider integration tests that require network and credentials
diff --git a/ruff.toml b/ruff.toml
new file mode 100644
index 0000000..3fb075f
--- /dev/null
+++ b/ruff.toml
@@ -0,0 +1,15 @@
+line-length = 110
+required-version = "==0.15.7"
+
+[lint]
+select = ["E", "F", "I", "B", "UP", "ASYNC", "SIM", "D"]
+
+[format]
+docstring-code-format = true
+
+[lint.pydocstyle]
+convention = "google"
+
+[lint.per-file-ignores]
+"src/churro_ocr/cli.py" = ["B008"]
+"tests/**/*.py" = ["D100", "D101", "D102", "D103", "D104", "D105", "D106", "D107"]
diff --git a/scripts/package_check.py b/scripts/package_check.py
new file mode 100644
index 0000000..8e23c23
--- /dev/null
+++ b/scripts/package_check.py
@@ -0,0 +1,339 @@
+"""Build, inspect, and smoke-test churro-ocr publish artifacts."""
+
+from __future__ import annotations
+
+import re
+import shutil
+import subprocess
+import sys
+import tarfile
+import tempfile
+import zipfile
+from email import message_from_string
+from email.message import Message
+from importlib import metadata
+from pathlib import Path
+
+from packaging.requirements import InvalidRequirement
+from packaging.requirements import Requirement
+
+ROOT = Path(__file__).resolve().parents[1]
+DIST_DIR = ROOT / "dist"
+BUILD_DIR = ROOT / "build"
+EGG_INFO_DIR = ROOT / "src" / "churro_ocr.egg-info"
+EXPECTED_PROJECT_URLS = {
+ "Homepage": "https://github.com/stanford-oval/Churro",
+ "Documentation": "https://stanford-oval.github.io/Churro/",
+ "Repository": "https://github.com/stanford-oval/Churro",
+ "Issues": "https://github.com/stanford-oval/Churro/issues",
+}
+EXPECTED_EXTRAS = {
+ "all",
+ "azure",
+ "hf",
+ "llm",
+ "local",
+ "mistral",
+ "pdf",
+ "vllm",
+}
+FORBIDDEN_ARTIFACT_SEGMENTS = ("/tests/", "/tooling/", "/scripts/")
+FORBIDDEN_ARTIFACT_SUFFIXES = ("PYPI_AUDIT.md",)
+REQUIREMENT_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+")
+ALLOWED_LICENSE_TOKENS = (
+ "apache",
+ "bsd",
+ "isc",
+ "mit",
+ "mozilla public license",
+ "mpl-2.0",
+ "python software foundation",
+)
+INCOMPATIBLE_LICENSE_TOKENS = (
+ "agpl",
+ "commercial",
+ "gnu affero",
+ "gpl",
+ "lgpl",
+)
+
+
+def _run(*args: str, cwd: Path | None = None) -> str:
+ completed = subprocess.run(
+ list(args),
+ check=True,
+ cwd=cwd or ROOT,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ return completed.stdout
+
+
+def _remove_if_exists(path: Path) -> None:
+ if path.is_dir():
+ shutil.rmtree(path)
+ return
+ if path.exists():
+ path.unlink()
+
+
+def _clean_build_artifacts() -> None:
+ for path in (BUILD_DIR, DIST_DIR, EGG_INFO_DIR):
+ _remove_if_exists(path)
+
+
+def _build_distributions() -> tuple[Path, Path]:
+ _run(sys.executable, "-m", "build", cwd=ROOT)
+ 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/.")
+ return wheel, sdist
+
+
+def _read_wheel_metadata(wheel: Path) -> tuple[Message, str]:
+ with zipfile.ZipFile(wheel) as zip_file:
+ metadata_name = next(name for name in zip_file.namelist() if name.endswith(".dist-info/METADATA"))
+ entry_points_name = next(
+ name for name in zip_file.namelist() if name.endswith(".dist-info/entry_points.txt")
+ )
+ metadata_message = message_from_string(zip_file.read(metadata_name).decode("utf-8"))
+ entry_points_text = zip_file.read(entry_points_name).decode("utf-8")
+ return metadata_message, entry_points_text
+
+
+def _twine_check(wheel: Path, sdist: Path) -> None:
+ _run(sys.executable, "-m", "twine", "check", str(wheel), str(sdist), cwd=ROOT)
+
+
+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}.")
+ if metadata_message["Requires-Python"] != ">=3.12":
+ raise RuntimeError("Requires-Python metadata no longer matches the documented support policy.")
+
+ 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}.")
+
+ provides_extra = set(metadata_message.get_all("Provides-Extra", []))
+ if provides_extra != EXPECTED_EXTRAS:
+ raise RuntimeError(f"Unexpected extras set: {sorted(provides_extra)!r}.")
+
+ requires_dist = metadata_message.get_all("Requires-Dist", [])
+ if not any(
+ requirement.startswith("vllm") and 'extra == "all"' in requirement for requirement in requires_dist
+ ):
+ raise RuntimeError("The all extra does not include vllm.")
+
+ if (
+ "[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.")
+
+
+def _assert_runtime_only_artifacts(wheel: Path, sdist: Path) -> None:
+ with zipfile.ZipFile(wheel) as zip_file:
+ wheel_names = zip_file.namelist()
+ 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}")
+ if any(normalized.endswith(suffix) for suffix in FORBIDDEN_ARTIFACT_SUFFIXES):
+ raise RuntimeError(f"Wheel unexpectedly includes repo-only documentation: {name}")
+
+ 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}")
+ if any(normalized.endswith(suffix) for suffix in FORBIDDEN_ARTIFACT_SUFFIXES):
+ raise RuntimeError(f"sdist unexpectedly includes repo-only documentation: {name}")
+
+
+def _venv_python(venv_dir: Path) -> Path:
+ return venv_dir / "bin" / "python"
+
+
+def _smoke_install(requirement: str, *, label: str, import_check: str) -> None:
+ with tempfile.TemporaryDirectory(prefix=f"churro-{label}-") as temp_dir:
+ temp_path = Path(temp_dir)
+ venv_dir = temp_path / "venv"
+ workspace_dir = temp_path / "workspace"
+ workspace_dir.mkdir()
+
+ _run(sys.executable, "-m", "venv", str(venv_dir), cwd=workspace_dir)
+ python = _venv_python(venv_dir)
+ _run(str(python), "-m", "pip", "install", "--upgrade", "pip", cwd=workspace_dir)
+ _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.")
+ _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.")
+
+
+def _requirement_name(requirement: str) -> str | None:
+ match = REQUIREMENT_NAME_PATTERN.match(requirement)
+ if match is None:
+ return None
+ return match.group(0).replace("_", "-").lower()
+
+
+def _audited_requirement(requirement: str) -> tuple[str, bool] | None:
+ try:
+ parsed = Requirement(requirement)
+ except InvalidRequirement:
+ name = _requirement_name(requirement)
+ if name is None:
+ return None
+ return name, True
+
+ marker = parsed.marker
+ marker_text = str(marker) if marker is not None else ""
+ if marker is not None and "extra" not in marker_text and not marker.evaluate():
+ return None
+
+ is_optional_extra = marker is not None and "extra" in marker_text
+ return parsed.name.replace("_", "-").lower(), not is_optional_extra
+
+
+def _direct_dependencies_to_audit(metadata_message: Message) -> dict[str, bool]:
+ direct_dependencies: dict[str, bool] = {}
+ for requirement in metadata_message.get_all("Requires-Dist", []):
+ parsed = _audited_requirement(requirement)
+ if parsed is None:
+ continue
+
+ name, is_required = parsed
+ direct_dependencies[name] = direct_dependencies.get(name, False) or is_required
+ return direct_dependencies
+
+
+def _read_distribution_license_text(distribution: metadata.Distribution) -> str:
+ metadata_message = distribution.metadata
+ parts: list[str] = []
+
+ for key in ("License-Expression", "License"):
+ parts.extend(value for value in metadata_message.get_all(key, []) if value)
+ parts.extend(
+ classifier
+ for classifier in metadata_message.get_all("Classifier", [])
+ if classifier.startswith("License ::")
+ )
+
+ for license_file in metadata_message.get_all("License-File", []):
+ path = distribution.locate_file(license_file)
+ if path.exists():
+ parts.append(path.read_text(errors="ignore")[:4_000])
+
+ if parts:
+ return "\n".join(parts).lower()
+
+ for file_path in distribution.files or []:
+ lowered = str(file_path).lower()
+ if "license" not in lowered and "copying" not in lowered:
+ continue
+ path = distribution.locate_file(file_path)
+ if path.exists():
+ parts.append(path.read_text(errors="ignore")[:4_000])
+ return "\n".join(parts).lower()
+
+
+def _audit_dependency_licenses(metadata_message: Message) -> None:
+ direct_dependencies = _direct_dependencies_to_audit(metadata_message)
+ incompatible: list[str] = []
+ unknown: list[str] = []
+ for dependency_name in sorted(direct_dependencies):
+ is_required = direct_dependencies[dependency_name]
+ try:
+ distribution = metadata.distribution(dependency_name)
+ except metadata.PackageNotFoundError:
+ if not is_required:
+ continue
+ unknown.append(f"{dependency_name} (not installed in the Pixi audit environment)")
+ continue
+
+ license_text = _read_distribution_license_text(distribution)
+ if any(token in license_text for token in INCOMPATIBLE_LICENSE_TOKENS):
+ incompatible.append(f"{dependency_name}=={distribution.version}")
+ continue
+ if any(token in license_text for token in ALLOWED_LICENSE_TOKENS):
+ continue
+ unknown.append(f"{dependency_name}=={distribution.version}")
+
+ if incompatible:
+ raise RuntimeError(
+ "Incompatible direct dependency licenses detected: " + ", ".join(incompatible) + "."
+ )
+ if unknown:
+ raise RuntimeError("Unknown direct dependency licenses detected: " + ", ".join(unknown) + ".")
+
+
+def main() -> int:
+ print("==> Cleaning build artifacts")
+ _clean_build_artifacts()
+
+ print("==> Building wheel and sdist")
+ wheel, sdist = _build_distributions()
+
+ print("==> Running twine check")
+ _twine_check(wheel, sdist)
+
+ print("==> Validating wheel metadata")
+ metadata_message, entry_points_text = _read_wheel_metadata(wheel)
+ _assert_metadata(metadata_message, entry_points_text)
+
+ print("==> Validating artifact contents")
+ _assert_runtime_only_artifacts(wheel, sdist)
+
+ print("==> Smoke-testing base wheel install")
+ _smoke_install(
+ wheel.resolve().as_uri(),
+ label="wheel",
+ import_check=(
+ "import churro_ocr; import churro_ocr.providers as providers; "
+ "assert hasattr(providers, 'OCRBackendSpec')"
+ ),
+ )
+
+ print("==> Smoke-testing base sdist install")
+ _smoke_install(
+ sdist.resolve().as_uri(),
+ label="sdist",
+ import_check=(
+ "import churro_ocr; import churro_ocr.providers as providers; "
+ "assert hasattr(providers, 'OCRBackendSpec')"
+ ),
+ )
+
+ print("==> Smoke-testing lightweight extras")
+ wheel_uri = wheel.resolve().as_uri()
+ _smoke_install(
+ f"churro-ocr[local] @ {wheel_uri}",
+ label="wheel-local",
+ import_check="import churro_ocr; import litellm",
+ )
+ _smoke_install(
+ f"churro-ocr[pdf] @ {wheel_uri}",
+ label="wheel-pdf",
+ import_check="import churro_ocr; import pypdfium2",
+ )
+
+ print("==> Auditing direct dependency licenses")
+ _audit_dependency_licenses(metadata_message)
+
+ print("==> package-check passed")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/churro_ocr/__init__.py b/src/churro_ocr/__init__.py
new file mode 100644
index 0000000..3d9fc09
--- /dev/null
+++ b/src/churro_ocr/__init__.py
@@ -0,0 +1,45 @@
+"""Library-first public API for churro-ocr."""
+
+from churro_ocr.document import DocumentOCRPipeline, DocumentOCRResult
+from churro_ocr.errors import ChurroError, ConfigurationError, ProviderError
+from churro_ocr.ocr import BatchOCRBackend, OCRBackend, OCRClient, OCRResult
+from churro_ocr.page_detection import (
+ DocumentPage,
+ DocumentPageDetector,
+ PageCandidate,
+ PageDetectionBackend,
+ PageDetectionRequest,
+ PageDetectionResult,
+ PageDetector,
+)
+from churro_ocr.templates import (
+ CHURRO_3B_MODEL_ID,
+ CHURRO_3B_XML_TEMPLATE,
+ DEFAULT_OCR_TEMPLATE,
+ HFChatTemplate,
+ OCRPromptTemplate,
+)
+
+__all__ = [
+ "CHURRO_3B_MODEL_ID",
+ "CHURRO_3B_XML_TEMPLATE",
+ "BatchOCRBackend",
+ "ChurroError",
+ "ConfigurationError",
+ "DocumentPage",
+ "DocumentOCRPipeline",
+ "DocumentOCRResult",
+ "DocumentPageDetector",
+ "DEFAULT_OCR_TEMPLATE",
+ "HFChatTemplate",
+ "OCRPromptTemplate",
+ "OCRBackend",
+ "OCRClient",
+ "OCRResult",
+ "PageDetectionBackend",
+ "PageDetector",
+ "PageCandidate",
+ "PageDetectionRequest",
+ "PageDetectionResult",
+ "ProviderError",
+]
diff --git a/src/churro_ocr/__main__.py b/src/churro_ocr/__main__.py
new file mode 100644
index 0000000..570c9d8
--- /dev/null
+++ b/src/churro_ocr/__main__.py
@@ -0,0 +1,6 @@
+"""Module entrypoint for `python -m churro_ocr`."""
+
+from churro_ocr.cli import main
+
+if __name__ == "__main__":
+ main()
diff --git a/src/churro_ocr/_internal/__init__.py b/src/churro_ocr/_internal/__init__.py
new file mode 100644
index 0000000..219a569
--- /dev/null
+++ b/src/churro_ocr/_internal/__init__.py
@@ -0,0 +1 @@
+"""Private implementation helpers for the churro-ocr package."""
diff --git a/src/churro_ocr/_internal/image.py b/src/churro_ocr/_internal/image.py
new file mode 100644
index 0000000..7a915a8
--- /dev/null
+++ b/src/churro_ocr/_internal/image.py
@@ -0,0 +1,61 @@
+"""Image loading, encoding, and normalization helpers."""
+
+from __future__ import annotations
+
+import base64
+from io import BytesIO
+from pathlib import Path
+
+from PIL import Image, ImageOps
+
+from churro_ocr.errors import ConfigurationError
+
+MAX_INLINE_IMAGE_DIM = 2_500
+
+
+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}")
+ with Image.open(resolved) as image:
+ normalized = ImageOps.exif_transpose(image)
+ assert normalized is not None
+ return normalized.copy()
+
+
+def ensure_rgb(image: Image.Image) -> Image.Image:
+ """Return an RGB image copy when needed."""
+ if image.mode == "RGB":
+ return image.copy()
+ return image.convert("RGB")
+
+
+def resize_image_to_fit(image: Image.Image, max_width: int, max_height: int) -> Image.Image:
+ """Resize an image to fit within the provided bounds."""
+ width, height = image.size
+ if width <= max_width and height <= max_height:
+ return image
+ scale = min(max_width / width, max_height / height)
+ return image.resize(
+ (max(1, int(width * scale)), max(1, int(height * scale))),
+ resample=Image.Resampling.LANCZOS,
+ )
+
+
+def prepare_ocr_image(image: Image.Image) -> Image.Image:
+ """Normalize and resize an image for OCR provider transport."""
+ return ensure_rgb(resize_image_to_fit(image, MAX_INLINE_IMAGE_DIM, MAX_INLINE_IMAGE_DIM))
+
+
+def image_to_base64(image: Image.Image, format_name: str | None = None) -> tuple[str, str]:
+ """Encode an image for provider transport."""
+ resolved_format = (format_name or image.format or "PNG").upper()
+ if resolved_format not in {"PNG", "JPEG", "WEBP"}:
+ resolved_format = "PNG"
+ mime_type = f"image/{resolved_format.lower()}"
+ buffer = BytesIO()
+ save_kwargs = {"quality": 95, "optimize": True} if resolved_format == "JPEG" else {}
+ image.save(buffer, format=resolved_format, **save_kwargs)
+ encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
+ return mime_type, encoded
diff --git a/src/churro_ocr/_internal/litellm.py b/src/churro_ocr/_internal/litellm.py
new file mode 100644
index 0000000..967d3c3
--- /dev/null
+++ b/src/churro_ocr/_internal/litellm.py
@@ -0,0 +1,356 @@
+"""Minimal LiteLLM wrapper with shared transport configuration."""
+
+from __future__ import annotations
+
+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 churro_ocr._internal.image import image_to_base64
+from churro_ocr.errors import ConfigurationError, ProviderError
+from churro_ocr.providers.specs import LiteLLMTransportConfig
+from churro_ocr.templates import OCRConversation
+
+_INITIALIZED = False
+_DISK_CACHE_DIR: str | None = None
+
+
+def _ensure_initialized() -> None:
+ global _INITIALIZED
+ if _INITIALIZED:
+ return
+ try:
+ import litellm
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ "LiteLLM-backed providers require the 'llm' extra. "
+ 'Install with `pip install "churro-ocr[llm]"`.'
+ ) from exc
+
+ litellm_any = cast(Any, litellm)
+ litellm_any.turn_off_message_logging = True
+ litellm_any.success_callback = []
+ litellm_any.failure_callback = []
+ with suppress(Exception):
+ litellm_any._logging._logged_requests = []
+ litellm_any.drop_params = True
+ litellm_any.suppress_debug_info = True
+ litellm_any.set_verbose = False
+
+ for logger_name in ("LiteLLM", "litellm", "LiteLLM Router", "LiteLLM Proxy"):
+ provider_logger = logging.getLogger(logger_name)
+ provider_logger.setLevel(logging.WARNING)
+ provider_logger.propagate = False
+
+ try: # pragma: no cover - defensive against LiteLLM internal changes
+ logging_worker = import_module("litellm.litellm_core_utils.logging_worker")
+ global_logging_worker = getattr(logging_worker, "GLOBAL_LOGGING_WORKER", 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:
+ if getattr(litellm, "turn_off_message_logging", False):
+ with suppress(Exception):
+ async_coroutine.close()
+ return
+ original_enqueue(async_coroutine)
+
+ global_logging_worker.ensure_initialized_and_enqueue = _enqueue_if_enabled
+ except Exception:
+ pass
+
+ _INITIALIZED = True
+
+
+def configure_disk_cache(*, disk_cache_dir: str | Path) -> None:
+ """Enable LiteLLM disk caching for subsequent requests."""
+ global _DISK_CACHE_DIR
+
+ _ensure_initialized()
+
+ from litellm import cache, input_callback
+ from litellm.caching.caching import enable_cache, update_cache
+
+ cache_dir = str(Path(disk_cache_dir).expanduser().resolve())
+ Path(cache_dir).mkdir(parents=True, exist_ok=True)
+
+ if cache_dir == _DISK_CACHE_DIR and cache is not None:
+ return
+
+ cache_kwargs: dict[str, Any] = {
+ "type": "disk",
+ "disk_cache_dir": cache_dir,
+ }
+ if cache is None or "cache" not in input_callback:
+ enable_cache(**cache_kwargs)
+ else:
+ update_cache(**cache_kwargs)
+
+ _DISK_CACHE_DIR = cache_dir
+
+
+class LiteLLMTransport:
+ """Shared LiteLLM transport for OCR and LLM page detection."""
+
+ def __init__(self, config: LiteLLMTransportConfig | None = None) -> None:
+ self._config = config or LiteLLMTransportConfig()
+ self._last_cost_usd: float | None = None
+ self._total_cost_usd: float = 0.0
+ self._request_count: int = 0
+ self._untracked_request_count: int = 0
+
+ @property
+ def config(self) -> LiteLLMTransportConfig:
+ """Return the transport config."""
+ return self._config
+
+ @property
+ def last_cost_usd(self) -> float | None:
+ """Return the cost of the most recent successful request, when available."""
+ return self._last_cost_usd
+
+ @property
+ def total_cost_usd(self) -> float:
+ """Return the cumulative cost of all successfully costed requests."""
+ return self._total_cost_usd
+
+ @property
+ def request_count(self) -> int:
+ """Return the number of successful completion requests made by this transport."""
+ return self._request_count
+
+ @property
+ def untracked_request_count(self) -> int:
+ """Return the number of successful requests whose cost could not be determined."""
+ return self._untracked_request_count
+
+ def prepare_messages(
+ self,
+ *,
+ system_prompt: str | None,
+ user_prompt: str | None,
+ images: Sequence[Image.Image] | None = None,
+ ) -> list[dict[str, Any]]:
+ """Build chat-style messages for multimodal provider calls."""
+ return _prepare_messages(
+ system_prompt=system_prompt,
+ user_prompt=user_prompt,
+ images=images,
+ image_detail=self._resolved_image_detail(),
+ )
+
+ def prepare_messages_from_conversation(
+ self,
+ conversation: OCRConversation,
+ ) -> list[dict[str, Any]]:
+ """Convert an OCR conversation into LiteLLM/OpenAI-style messages."""
+ return _prepare_messages_from_conversation(
+ conversation,
+ image_detail=self._resolved_image_detail(),
+ )
+
+ async def complete_text(
+ self,
+ *,
+ model: str,
+ messages: list[dict[str, Any]],
+ timeout_seconds: int = 600,
+ output_json: bool = False,
+ ) -> str:
+ """Run a LiteLLM completion and return the text content."""
+ if self._config.cache_dir is not None:
+ configure_disk_cache(disk_cache_dir=self._config.cache_dir)
+
+ _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)
+
+ try:
+ response = await acompletion(**kwargs)
+ except Exception as exc: # pragma: no cover - provider-specific failure path
+ raise ProviderError(f"LiteLLM request failed for model '{model}': {exc}") from exc
+ self._record_response_cost(model=model, response=response)
+
+ answer = response.choices[0].message.content
+ if not isinstance(answer, str) or not answer.strip():
+ raise ProviderError(f"LiteLLM returned empty output for model '{model}'.")
+ return answer
+
+ def _resolved_image_detail(self) -> str | None:
+ return "high" if self._config.image_detail is None else self._config.image_detail
+
+ def _record_response_cost(self, *, model: str, response: object) -> None:
+ """Update cumulative request/cost counters from a successful LiteLLM response."""
+ self._request_count += 1
+ cost = _extract_response_cost(model=model, response=response)
+ self._last_cost_usd = cost
+ if cost is None:
+ self._untracked_request_count += 1
+ return
+ self._total_cost_usd += cost
+
+
+def _prepare_messages(
+ *,
+ system_prompt: str | None,
+ user_prompt: str | None,
+ images: Sequence[Image.Image] | None = None,
+ image_detail: str | None = None,
+) -> list[dict[str, Any]]:
+ """Build chat-style messages for multimodal provider calls."""
+ content: list[dict[str, Any]] = []
+ for image in images or []:
+ mime_type, encoded = image_to_base64(image)
+ payload: dict[str, Any] = {
+ "url": f"data:{mime_type};base64,{encoded}",
+ }
+ if image_detail:
+ payload["detail"] = image_detail
+ content.append({"type": "image_url", "image_url": payload})
+ if user_prompt:
+ content.append({"type": "text", "text": user_prompt})
+
+ messages: list[dict[str, Any]] = []
+ if system_prompt:
+ messages.append(
+ {
+ "role": "system",
+ "content": [{"type": "text", "text": system_prompt}],
+ }
+ )
+ messages.append({"role": "user", "content": content})
+ return messages
+
+
+def _extract_response_cost(*, model: str, response: object) -> float | None:
+ """Best-effort extraction of a response's USD cost from LiteLLM metadata."""
+ hidden_params = getattr(response, "_hidden_params", None)
+ if isinstance(hidden_params, dict):
+ raw_cost = hidden_params.get("response_cost")
+ if isinstance(raw_cost, (int, float)):
+ return float(raw_cost)
+
+ try:
+ from litellm import completion_cost
+ except Exception:
+ return None
+
+ try:
+ cost = completion_cost(completion_response=response, model=model)
+ except Exception:
+ return None
+ if not isinstance(cost, (int, float)):
+ return None
+ return float(cost)
+
+
+def _prepare_messages_from_conversation(
+ conversation: OCRConversation,
+ *,
+ image_detail: str | None = None,
+) -> list[dict[str, Any]]:
+ """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: list[dict[str, Any]] = []
+ for item in content_items:
+ if item.get("type") == "image":
+ image = cast("Image.Image", item["image"])
+ mime_type, encoded = image_to_base64(image)
+ payload: dict[str, Any] = {"url": f"data:{mime_type};base64,{encoded}"}
+ if image_detail:
+ payload["detail"] = image_detail
+ content.append({"type": "image_url", "image_url": payload})
+ continue
+ if item.get("type") == "text":
+ content.append({"type": "text", "text": item["text"]})
+ continue
+ content.append(dict(item))
+ messages.append({"role": message["role"], "content": content})
+ return messages
+
+
+def prepare_messages(
+ *,
+ system_prompt: str | None,
+ user_prompt: str | None,
+ images: Sequence[Image.Image] | None = None,
+ image_detail: str | None = None,
+) -> list[dict[str, Any]]:
+ """Build chat-style messages for multimodal provider calls."""
+ return _prepare_messages(
+ system_prompt=system_prompt,
+ user_prompt=user_prompt,
+ images=images,
+ image_detail=image_detail,
+ )
+
+
+def prepare_messages_from_conversation(
+ conversation: OCRConversation,
+ *,
+ image_detail: str | None = None,
+) -> list[dict[str, Any]]:
+ """Convert a structured OCR conversation into LiteLLM/OpenAI-style messages."""
+ return _prepare_messages_from_conversation(
+ conversation,
+ image_detail=image_detail,
+ )
+
+
+async def complete_text(
+ *,
+ model: str,
+ messages: list[dict[str, Any]],
+ api_base: str | None = None,
+ api_key: str | None = None,
+ api_version: str | None = None,
+ timeout_seconds: int = 600,
+ output_json: bool = False,
+ completion_kwargs: dict[str, object] | None = None,
+) -> str:
+ """Run a LiteLLM completion and return the text content."""
+ transport = LiteLLMTransport(
+ LiteLLMTransportConfig(
+ api_base=api_base,
+ api_key=api_key,
+ api_version=api_version,
+ completion_kwargs=dict(completion_kwargs or {}),
+ )
+ )
+ return await transport.complete_text(
+ model=model,
+ messages=messages,
+ timeout_seconds=timeout_seconds,
+ output_json=output_json,
+ )
+
+
+__all__ = [
+ "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
new file mode 100644
index 0000000..f11de42
--- /dev/null
+++ b/src/churro_ocr/_internal/logging.py
@@ -0,0 +1,64 @@
+"""Internal logging utilities for the standalone churro-ocr package."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from loguru import logger as _loguru_logger
+from rich.logging import RichHandler
+
+
+def _configure_default_logger() -> Any:
+ _loguru_logger.remove()
+ _loguru_logger.add(
+ RichHandler(markup=True, show_time=False),
+ level="WARNING",
+ format="{message}",
+ )
+ return _loguru_logger
+
+
+_default_logger = _configure_default_logger().bind(app="churro-ocr")
+
+
+class _LoggerAdapter:
+ """Compatibility wrapper for stdlib-style formatting on top of the default logger."""
+
+ __slots__ = ("_logger",)
+
+ def __init__(self, wrapped_logger: Any) -> None:
+ self._logger = wrapped_logger
+
+ def _format(self, message: str, *args: object) -> str:
+ return message % args if args else message
+
+ def debug(self, message: str, *args: object) -> None:
+ self._logger.debug(self._format(message, *args))
+
+ def info(self, message: str, *args: object) -> None:
+ self._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))
+ return
+ self._logger.info(self._format(message, *args))
+
+ def warning(self, message: str, *args: object) -> None:
+ self._logger.warning(self._format(message, *args))
+
+ def error(self, message: str, *args: object) -> None:
+ self._logger.error(self._format(message, *args))
+
+ def critical(self, message: str, *args: object) -> None:
+ self._logger.critical(self._format(message, *args))
+
+ def exception(self, message: str, *args: object) -> None:
+ self._logger.exception(self._format(message, *args))
+
+ def log(self, level: str, message: str, *args: object) -> None:
+ self._logger.log(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
new file mode 100644
index 0000000..4f89194
--- /dev/null
+++ b/src/churro_ocr/_internal/pdf.py
@@ -0,0 +1,44 @@
+"""PDF rasterization helpers."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from PIL import Image
+
+from churro_ocr.errors import ConfigurationError
+
+
+def rasterize_pdf(path: str | Path, *, dpi: int = 300) -> list[Image.Image]:
+ """Rasterize a PDF into PIL images."""
+ try:
+ import pypdfium2
+ except ImportError as exc: # pragma: no cover - depends on optional extra
+ raise ConfigurationError(
+ "PDF support requires the 'pdf' extra. Install with `pip install \"churro-ocr[pdf]\"`."
+ ) from exc
+
+ resolved = Path(path)
+ if not resolved.exists():
+ raise ConfigurationError(f"PDF path does not exist: {resolved}")
+
+ images: list[Image.Image] = []
+ scale = max(dpi, 1) / 72.0
+ document = pypdfium2.PdfDocument(str(resolved))
+ try:
+ for page in document:
+ try:
+ bitmap = page.render(scale=scale)
+ try:
+ rendered = bitmap.to_pil()
+ try:
+ images.append(rendered.convert("RGB"))
+ finally:
+ rendered.close()
+ finally:
+ bitmap.close()
+ finally:
+ page.close()
+ finally:
+ document.close()
+ return images
diff --git a/src/churro_ocr/_internal/prompt_logging.py b/src/churro_ocr/_internal/prompt_logging.py
new file mode 100644
index 0000000..6c84906
--- /dev/null
+++ b/src/churro_ocr/_internal/prompt_logging.py
@@ -0,0 +1,84 @@
+"""Helpers for one-time OCR prompt payload logging."""
+
+from __future__ import annotations
+
+import json
+from base64 import b64encode
+from collections.abc import Callable
+from threading import Lock
+
+from PIL import Image
+
+from churro_ocr._internal.image import image_to_base64
+from churro_ocr._internal.logging import logger
+
+_IMAGE_PREVIEW_CHARS = 96
+
+
+def _truncate_text(value: str, *, limit: int = _IMAGE_PREVIEW_CHARS) -> str:
+ if len(value) <= limit:
+ return value
+ return f"{value[:limit]}..."
+
+
+def _encode_image_preview(image: Image.Image, *, format_name: str | None = None) -> dict[str, object]:
+ mime_type, encoded = image_to_base64(image, format_name)
+ return {
+ "image_size": image.size,
+ "image_mode": image.mode,
+ "image_preview": _truncate_text(f"data:{mime_type};base64,{encoded}"),
+ }
+
+
+def _encode_bytes_preview(payload: bytes, *, mime_type: str) -> str:
+ encoded = b64encode(payload).decode("utf-8")
+ return _truncate_text(f"data:{mime_type};base64,{encoded}")
+
+
+def _sanitize_prompt_payload(payload: object) -> object:
+ if isinstance(payload, Image.Image):
+ return {
+ "type": "image",
+ **_encode_image_preview(payload),
+ }
+ if isinstance(payload, str) and payload.startswith("data:") and ";base64," in payload:
+ return _truncate_text(payload)
+ if isinstance(payload, bytes):
+ return {
+ "type": "bytes",
+ "byte_length": len(payload),
+ "data_preview": _encode_bytes_preview(payload, mime_type="application/octet-stream"),
+ }
+ if isinstance(payload, dict):
+ return {str(key): _sanitize_prompt_payload(value) for key, value in payload.items()}
+ if isinstance(payload, list):
+ return [_sanitize_prompt_payload(item) for item in payload]
+ if isinstance(payload, tuple):
+ return [_sanitize_prompt_payload(item) for item in payload]
+ return payload
+
+
+def log_prompt_payload_once(
+ *,
+ payload: object,
+ provider_name: str,
+ has_logged: Callable[[], bool],
+ lock: Lock,
+ set_logged: Callable[[], None],
+) -> None:
+ """Log one OCR prompt payload preview for a backend instance."""
+ if has_logged():
+ return
+ with lock:
+ if has_logged():
+ return
+ sanitized_payload = _sanitize_prompt_payload(payload)
+ logger.debug(
+ "First OCR prompt payload for %s:\n%s",
+ provider_name,
+ json.dumps(sanitized_payload, ensure_ascii=False, indent=2, default=str),
+ )
+ set_logged()
+
+
+__all__ = ["log_prompt_payload_once"]
diff --git a/src/churro_ocr/_internal/runtime.py b/src/churro_ocr/_internal/runtime.py
new file mode 100644
index 0000000..24f3c5e
--- /dev/null
+++ b/src/churro_ocr/_internal/runtime.py
@@ -0,0 +1,24 @@
+"""Runtime helpers shared by sync wrappers."""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Coroutine
+from typing import Any, TypeVar
+
+T = TypeVar("T")
+
+
+def run_sync[T](awaitable: Coroutine[Any, Any, T]) -> T:
+ """Run an awaitable from sync code.
+
+ Raises:
+ RuntimeError: If called from a running event loop.
+ """
+ try:
+ asyncio.get_running_loop()
+ except RuntimeError:
+ return asyncio.run(awaitable)
+ raise RuntimeError(
+ "Synchronous churro-ocr APIs cannot be used from an active event loop. Use the async API instead."
+ )
diff --git a/src/churro_ocr/cli.py b/src/churro_ocr/cli.py
new file mode 100644
index 0000000..8715bcd
--- /dev/null
+++ b/src/churro_ocr/cli.py
@@ -0,0 +1,235 @@
+"""Minimal public CLI for OCR and page detection."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import typer
+
+from churro_ocr.ocr import OCRClient
+from churro_ocr.page_detection import DocumentPage, DocumentPageDetector, PageDetectionRequest
+from churro_ocr.providers import (
+ AzureDocumentIntelligenceOptions,
+ AzurePageDetector,
+ HuggingFaceOptions,
+ LiteLLMTransportConfig,
+ LLMPageDetector,
+ MistralOptions,
+ OCRBackendSpec,
+ OpenAICompatibleOptions,
+ VLLMOptions,
+ build_ocr_backend,
+)
+
+app = typer.Typer(help="churro-ocr library-first CLI")
+
+
+def _build_ocr_backend(
+ *,
+ backend: str,
+ model: str | None,
+ endpoint: str | None,
+ api_key: str | None,
+ base_url: str | None,
+ api_version: str | None,
+):
+ if backend == "litellm":
+ if not model:
+ raise typer.BadParameter("--model is required for backend=litellm")
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model=model,
+ transport=LiteLLMTransportConfig(
+ api_base=base_url,
+ api_key=api_key,
+ api_version=api_version,
+ ),
+ )
+ )
+ if backend == "openai-compatible":
+ if not model or not base_url or not api_key:
+ raise typer.BadParameter(
+ "--model, --base-url, and --api-key are required for backend=openai-compatible"
+ )
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="openai-compatible",
+ model=model,
+ transport=LiteLLMTransportConfig(
+ api_base=base_url,
+ api_key=api_key,
+ api_version=api_version,
+ ),
+ options=OpenAICompatibleOptions(),
+ )
+ )
+ if backend == "azure":
+ if not endpoint or not api_key:
+ raise typer.BadParameter("--endpoint and --api-key are required for backend=azure")
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="azure",
+ model=model,
+ options=AzureDocumentIntelligenceOptions(
+ endpoint=endpoint,
+ api_key=api_key,
+ ),
+ )
+ )
+ if backend == "mistral":
+ if not api_key:
+ raise typer.BadParameter("--api-key is required for backend=mistral")
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="mistral",
+ model=model or "mistral-ocr-latest",
+ options=MistralOptions(api_key=api_key),
+ )
+ )
+ if backend == "hf":
+ if not model:
+ raise typer.BadParameter("--model is required for backend=hf")
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="hf",
+ model=model,
+ options=HuggingFaceOptions(model_kwargs={"device_map": "auto", "torch_dtype": "auto"}),
+ )
+ )
+ if backend == "vllm":
+ if not model:
+ raise typer.BadParameter("--model is required for backend=vllm")
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="vllm",
+ model=model,
+ options=VLLMOptions(),
+ )
+ )
+ raise typer.BadParameter(f"Unsupported backend: {backend}")
+
+
+def _build_page_detector(
+ *,
+ page_detector: str,
+ model: str | None,
+ endpoint: str | None,
+ api_key: str | None,
+ base_url: str | None,
+ api_version: str | None,
+):
+ transport = None
+ if base_url or api_key or api_version:
+ transport = LiteLLMTransportConfig(
+ api_base=base_url,
+ api_key=api_key,
+ api_version=api_version,
+ )
+ detector_backend = None
+ if page_detector == "llm":
+ if not model:
+ raise typer.BadParameter("--model is required when --page-detector=llm")
+ 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")
+ detector_backend = AzurePageDetector(endpoint=endpoint, api_key=api_key)
+ return detector_backend
+
+
+@app.command("transcribe")
+def transcribe_command(
+ image: Path = typer.Option(..., exists=True, dir_okay=False, readable=True),
+ backend: str = typer.Option("litellm"),
+ model: str | None = typer.Option(None),
+ endpoint: str | None = typer.Option(None),
+ api_key: str | None = typer.Option(None),
+ base_url: str | None = typer.Option(None),
+ api_version: str | None = typer.Option(None),
+ output: Path | None = typer.Option(None),
+) -> None:
+ """Transcribe text from a single image."""
+ ocr_backend = _build_ocr_backend(
+ backend=backend,
+ model=model,
+ endpoint=endpoint,
+ api_key=api_key,
+ base_url=base_url,
+ api_version=api_version,
+ )
+ result = OCRClient(ocr_backend).ocr(DocumentPage.from_image_path(image))
+ if output:
+ output.write_text(result.text or "")
+ typer.echo(str(output))
+ return
+ typer.echo(result.text or "")
+
+
+@app.command("extract-pages")
+def extract_pages_command(
+ image: Path | None = typer.Option(
+ None,
+ exists=True,
+ dir_okay=False,
+ readable=True,
+ help="Input image to split into page crops.",
+ ),
+ pdf: Path | None = typer.Option(
+ None,
+ exists=True,
+ dir_okay=False,
+ readable=True,
+ help="Input PDF to rasterize and split into page crops.",
+ ),
+ output_dir: Path = typer.Option(
+ ...,
+ file_okay=False,
+ writable=True,
+ help=(
+ "Directory where extracted pages are written as sequential PNG files such as "
+ "`page_0000.png`, `page_0001.png`, and so on."
+ ),
+ ),
+ page_detector: str = typer.Option("none"),
+ model: str | None = typer.Option(None),
+ endpoint: str | None = typer.Option(None),
+ api_key: str | None = typer.Option(None),
+ base_url: str | None = typer.Option(None),
+ api_version: str | None = typer.Option(None),
+ dpi: int = typer.Option(300),
+ trim_margin: int = typer.Option(30),
+) -> 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.")
+ detector_backend = _build_page_detector(
+ page_detector=page_detector,
+ model=model,
+ endpoint=endpoint,
+ api_key=api_key,
+ base_url=base_url,
+ api_version=api_version,
+ )
+ page_detector_client = DocumentPageDetector(backend=detector_backend)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ if image is not None:
+ result = page_detector_client.detect_image_sync(
+ PageDetectionRequest(image_path=image, trim_margin=trim_margin)
+ )
+ else:
+ assert pdf is not None
+ result = page_detector_client.detect_pdf_sync(pdf, dpi=dpi, trim_margin=trim_margin)
+
+ for page in result.pages:
+ output_path = output_dir / f"page_{page.page_index:04d}.png"
+ page.image.save(output_path)
+ typer.echo(str(output_path))
+
+
+def main() -> None:
+ """Console-script entrypoint."""
+ app()
diff --git a/src/churro_ocr/document.py b/src/churro_ocr/document.py
new file mode 100644
index 0000000..fca50c4
--- /dev/null
+++ b/src/churro_ocr/document.py
@@ -0,0 +1,221 @@
+"""Document-level OCR pipeline built on the page detection and OCR APIs."""
+
+from __future__ import annotations
+
+import asyncio
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from churro_ocr._internal.runtime import run_sync
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.ocr import OCRBackendLike, OCRClient, OCRResult
+from churro_ocr.page_detection import (
+ DocumentPage,
+ DocumentPageDetector,
+ PageDetectionBackendLike,
+ PageDetectionRequest,
+)
+
+
+@dataclass(slots=True)
+class DocumentOCRResult:
+ """Document OCR output across all detected pages.
+
+ :param pages: OCR-enriched pages in output order.
+ :param source_type: Input source type, typically ``"image"`` or ``"pdf"``.
+ :param metadata: Document-level metadata carried forward from page detection.
+ """
+
+ pages: list[DocumentPage]
+ source_type: str
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+ def texts(self) -> list[str]:
+ """Return OCR text for each page in order.
+
+ :returns: Plain OCR text for each page. Missing page text is normalized to ``""``.
+ """
+ return [page.text or "" for page in self.pages]
+
+ def as_ocr_results(self) -> list[OCRResult]:
+ """Return plain OCR results in page order.
+
+ :returns: ``OCRResult`` objects derived from the current pages.
+ """
+ return [
+ OCRResult(
+ text=page.text or "",
+ provider_name=page.provider_name or "",
+ model_name=page.model_name or "",
+ metadata=dict(page.ocr_metadata),
+ )
+ for page in self.pages
+ ]
+
+
+class DocumentOCRPipeline:
+ """Run page detection and OCR as one document-level pipeline.
+
+ The pipeline is the highest-level API in the package. It detects pages from
+ an image or PDF, runs OCR on each detected page, and preserves the page
+ objects in the final result.
+ """
+
+ def __init__(
+ self,
+ ocr_backend: OCRBackendLike,
+ *,
+ page_detector: DocumentPageDetector | None = None,
+ detection_backend: PageDetectionBackendLike | None = None,
+ max_concurrency: int = 8,
+ ) -> None:
+ """Create a document OCR pipeline.
+
+ :param ocr_backend: OCR backend or async OCR callable used for each page.
+ :param page_detector: Optional fully constructed page detector to reuse.
+ :param detection_backend: Optional low-level detection backend used when
+ ``page_detector`` is not provided.
+ :param max_concurrency: Maximum number of page OCR jobs run at once.
+ :raises ConfigurationError: If ``max_concurrency`` is less than 1.
+ """
+ if max_concurrency < 1:
+ raise ConfigurationError("DocumentOCRPipeline max_concurrency must be at least 1.")
+ self._ocr_client = OCRClient(ocr_backend)
+ self._page_detector = page_detector or DocumentPageDetector(backend=detection_backend)
+ self.max_concurrency = max_concurrency
+
+ async def process_image(
+ self,
+ request: PageDetectionRequest,
+ *,
+ ocr_metadata: dict[str, Any] | None = None,
+ ) -> DocumentOCRResult:
+ """Detect pages and OCR a single input image.
+
+ :param request: Image detection request describing the source image.
+ :param ocr_metadata: Optional caller-side metadata merged into each page
+ before OCR runs.
+ :returns: Document OCR result preserving page order and page images.
+ """
+ detection_result = await self._page_detector.detect_image(request)
+ return await self._ocr_detection_result(
+ detection_result.pages,
+ detection_result.source_type,
+ detection_result.metadata,
+ ocr_metadata,
+ )
+
+ def process_image_sync(
+ self,
+ request: PageDetectionRequest,
+ *,
+ ocr_metadata: dict[str, Any] | None = None,
+ ) -> DocumentOCRResult:
+ """Synchronously detect pages and OCR a single input image.
+
+ :param request: Image detection request describing the source image.
+ :param ocr_metadata: Optional caller-side metadata merged into each page
+ before OCR runs.
+ :returns: Document OCR result preserving page order and page images.
+ """
+ return run_sync(self.process_image(request, ocr_metadata=ocr_metadata))
+
+ async def process_pdf(
+ self,
+ path: str | Path,
+ *,
+ dpi: int = 300,
+ trim_margin: int = 30,
+ ocr_metadata: dict[str, Any] | None = None,
+ ) -> DocumentOCRResult:
+ """Rasterize, detect pages, and OCR a PDF.
+
+ :param path: PDF path to rasterize and process.
+ :param dpi: Rasterization DPI used before page detection.
+ :param trim_margin: Pixel margin added around detected crops.
+ :param ocr_metadata: Optional caller-side metadata merged into each page
+ before OCR runs.
+ :returns: Document OCR result across the rasterized PDF pages.
+ """
+ detection_result = await self._page_detector.detect_pdf(
+ path,
+ dpi=dpi,
+ trim_margin=trim_margin,
+ )
+ return await self._ocr_detection_result(
+ detection_result.pages,
+ detection_result.source_type,
+ detection_result.metadata,
+ ocr_metadata,
+ )
+
+ def process_pdf_sync(
+ self,
+ path: str | Path,
+ *,
+ dpi: int = 300,
+ trim_margin: int = 30,
+ ocr_metadata: dict[str, Any] | None = None,
+ ) -> DocumentOCRResult:
+ """Synchronously rasterize, detect pages, and OCR a PDF.
+
+ :param path: PDF path to rasterize and process.
+ :param dpi: Rasterization DPI used before page detection.
+ :param trim_margin: Pixel margin added around detected crops.
+ :param ocr_metadata: Optional caller-side metadata merged into each page
+ before OCR runs.
+ :returns: Document OCR result across the rasterized PDF pages.
+ """
+ return run_sync(
+ self.process_pdf(
+ path,
+ dpi=dpi,
+ trim_margin=trim_margin,
+ ocr_metadata=ocr_metadata,
+ )
+ )
+
+ async def _ocr_detection_result(
+ self,
+ detected_pages: list[DocumentPage],
+ source_type: str,
+ metadata: dict[str, Any],
+ ocr_metadata: dict[str, Any] | None,
+ ) -> DocumentOCRResult:
+ semaphore = asyncio.Semaphore(self.max_concurrency)
+
+ async def _ocr_page_with_limit(page: DocumentPage) -> DocumentPage:
+ async with semaphore:
+ return await self._ocr_page(page, ocr_metadata=ocr_metadata)
+
+ results = await asyncio.gather(*(_ocr_page_with_limit(page) for page in detected_pages))
+ return DocumentOCRResult(
+ pages=results,
+ source_type=source_type,
+ metadata=dict(metadata),
+ )
+
+ async def _ocr_page(
+ self,
+ page: DocumentPage,
+ *,
+ ocr_metadata: dict[str, Any] | None,
+ ) -> DocumentPage:
+ page_metadata = dict(page.metadata)
+ page_metadata.update(ocr_metadata or {})
+ page_metadata.setdefault("page_index", page.page_index)
+ page_metadata.setdefault("source_index", page.source_index)
+ ocr_page = page.__class__(
+ page_index=page.page_index,
+ source_index=page.source_index,
+ image=page.image,
+ bbox=page.bbox,
+ polygon=page.polygon,
+ metadata=page_metadata,
+ text=page.text,
+ provider_name=page.provider_name,
+ model_name=page.model_name,
+ ocr_metadata=dict(page.ocr_metadata),
+ )
+ return await self._ocr_client.aocr(ocr_page)
diff --git a/src/churro_ocr/errors.py b/src/churro_ocr/errors.py
new file mode 100644
index 0000000..cddebc2
--- /dev/null
+++ b/src/churro_ocr/errors.py
@@ -0,0 +1,15 @@
+"""Public exception types for the churro-ocr package."""
+
+from __future__ import annotations
+
+
+class ChurroError(RuntimeError):
+ """Base exception for package-level failures."""
+
+
+class ConfigurationError(ChurroError):
+ """Raised when a backend is missing required runtime configuration."""
+
+
+class ProviderError(ChurroError):
+ """Raised when an OCR or page detection provider returns an unusable response."""
diff --git a/src/churro_ocr/ocr.py b/src/churro_ocr/ocr.py
new file mode 100644
index 0000000..2f6f5f7
--- /dev/null
+++ b/src/churro_ocr/ocr.py
@@ -0,0 +1,193 @@
+"""Public OCR interfaces."""
+
+from __future__ import annotations
+
+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 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
+
+
+@dataclass(slots=True)
+class OCRResult:
+ """Provider-agnostic OCR result.
+
+ :param text: OCR text after any backend-specific postprocessing.
+ :param provider_name: Stable provider identifier attached to the result.
+ :param model_name: Human-readable model name attached to the result.
+ :param metadata: Provider-returned metadata for this OCR call.
+ """
+
+ text: str
+ provider_name: str
+ model_name: str
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+@runtime_checkable
+class OCRBackend(Protocol):
+ """Async OCR backend interface."""
+
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ """Run OCR for one page.
+
+ :param page: Page image and page metadata to transcribe.
+ :returns: Provider-agnostic OCR result for the page.
+ """
+ ...
+
+
+@runtime_checkable
+class BatchOCRBackend(Protocol):
+ """Async batch OCR backend interface."""
+
+ async def ocr_batch(self, pages: list[DocumentPage]) -> list[OCRResult]:
+ """Run OCR for multiple pages in one batch.
+
+ :param pages: Pages to transcribe in batch order.
+ :returns: OCR results in the same order as ``pages``.
+ """
+ ...
+
+
+OCRCallable = Callable[[DocumentPage], Awaitable[OCRResult]]
+OCRBackendLike = OCRBackend | OCRCallable
+
+
+def prepare_ocr_page(page: DocumentPage) -> DocumentPage:
+ """Return a page copy with the shared OCR image preprocessing applied.
+
+ :param page: Page to preprocess for OCR.
+ :returns: Copy of ``page`` with its image replaced by the preprocessed image.
+ """
+ return replace(page, image=prepare_ocr_image(page.image))
+
+
+class OCRClient:
+ """User-facing OCR client with page-first sync and async entrypoints."""
+
+ def __init__(self, backend: OCRBackendLike) -> None:
+ """Create an OCR client.
+
+ :param backend: OCR backend or async callable used for page OCR.
+ """
+ self._backend = backend
+
+ async def aocr(self, page: DocumentPage) -> DocumentPage:
+ """Run OCR asynchronously for one page.
+
+ :param page: Page to transcribe.
+ :returns: Copy of ``page`` with OCR output attached.
+ """
+ if callable(self._backend) and not isinstance(self._backend, OCRBackend):
+ result = await self._backend(page)
+ else:
+ assert isinstance(self._backend, OCRBackend)
+ result = await self._backend.ocr(page)
+ return page.with_ocr(
+ text=result.text,
+ provider_name=result.provider_name,
+ model_name=result.model_name,
+ ocr_metadata=result.metadata,
+ )
+
+ def ocr(self, page: DocumentPage) -> DocumentPage:
+ """Run OCR synchronously for one page.
+
+ :param page: Page to transcribe.
+ :returns: Copy of ``page`` with OCR output attached.
+ """
+ return run_sync(self.aocr(page))
+
+ async def aocr_image(
+ self,
+ *,
+ image: Image.Image | None = None,
+ image_path: str | Path | None = None,
+ page_index: int = 0,
+ source_index: int = 0,
+ metadata: dict[str, Any] | None = None,
+ ) -> DocumentPage:
+ """Create a single page from an image input and OCR it.
+
+ :param image: In-memory page image. Mutually exclusive with ``image_path``.
+ :param image_path: Path to a page image on disk. Mutually exclusive with ``image``.
+ :param page_index: Page position to attach to the generated page.
+ :param source_index: Original source index to attach to the generated page.
+ :param metadata: Optional caller-side metadata attached before OCR runs.
+ :returns: OCR-enriched page object.
+ :raises ConfigurationError: If both or neither of ``image`` and
+ ``image_path`` are provided.
+ """
+ page = _page_from_image_input(
+ image=image,
+ image_path=image_path,
+ page_index=page_index,
+ source_index=source_index,
+ metadata=metadata,
+ )
+ return await self.aocr(page)
+
+ def ocr_image(
+ self,
+ *,
+ image: Image.Image | None = None,
+ image_path: str | Path | None = None,
+ page_index: int = 0,
+ source_index: int = 0,
+ metadata: dict[str, Any] | None = None,
+ ) -> DocumentPage:
+ """Create a single page from an image input and OCR it synchronously.
+
+ :param image: In-memory page image. Mutually exclusive with ``image_path``.
+ :param image_path: Path to a page image on disk. Mutually exclusive with ``image``.
+ :param page_index: Page position to attach to the generated page.
+ :param source_index: Original source index to attach to the generated page.
+ :param metadata: Optional caller-side metadata attached before OCR runs.
+ :returns: OCR-enriched page object.
+ :raises ConfigurationError: If both or neither of ``image`` and
+ ``image_path`` are provided.
+ """
+ return run_sync(
+ self.aocr_image(
+ image=image,
+ image_path=image_path,
+ page_index=page_index,
+ source_index=source_index,
+ metadata=metadata,
+ )
+ )
+
+
+def _page_from_image_input(
+ *,
+ image: Image.Image | None,
+ image_path: str | Path | None,
+ page_index: int,
+ source_index: int,
+ metadata: dict[str, Any] | None,
+) -> DocumentPage:
+ if (image is None) == (image_path is None):
+ raise ConfigurationError("OCR image helpers require exactly one of `image` or `image_path`.")
+ if image is not None:
+ return DocumentPage.from_image(
+ image,
+ page_index=page_index,
+ source_index=source_index,
+ metadata=metadata,
+ )
+ if image_path is not None:
+ return DocumentPage.from_image_path(
+ image_path,
+ page_index=page_index,
+ source_index=source_index,
+ metadata=metadata,
+ )
+ raise AssertionError("Unreachable exact-one image input guard.")
diff --git a/src/churro_ocr/page_detection.py b/src/churro_ocr/page_detection.py
new file mode 100644
index 0000000..d0c6d86
--- /dev/null
+++ b/src/churro_ocr/page_detection.py
@@ -0,0 +1,395 @@
+"""Public page detection interfaces."""
+
+from __future__ import annotations
+
+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, ImageDraw
+
+from churro_ocr._internal.image import load_image
+from churro_ocr._internal.pdf import rasterize_pdf
+from churro_ocr._internal.runtime import run_sync
+from churro_ocr.errors import ConfigurationError
+
+
+@dataclass(slots=True)
+class PageCandidate:
+ """Intermediate page candidate returned by a page detector.
+
+ :param bbox: Bounding box in source-image coordinates.
+ :param image: Optional already-cropped page image. When provided, detection
+ callers use this image directly instead of cropping from ``bbox`` or ``polygon``.
+ :param polygon: Optional polygon in source-image coordinates.
+ :param metadata: Detector-side metadata attached to the candidate.
+ """
+
+ bbox: tuple[float, float, float, float] | None = None
+ image: Image.Image | None = None
+ polygon: tuple[tuple[float, float], ...] = ()
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+@dataclass(slots=True)
+class DocumentPage:
+ """A document page image with optional OCR output attached.
+
+ :param page_index: Page position in the current output sequence.
+ :param image: Page image.
+ :param source_index: Index of the original source item that produced the page.
+ :param bbox: Bounding box in source-image coordinates when available.
+ :param polygon: Polygon in source-image coordinates when available.
+ :param metadata: Caller-side or detector-side metadata for the page.
+ :param text: OCR text attached to the page when OCR has been run.
+ :param provider_name: Provider identifier attached by OCR.
+ :param model_name: Model name attached by OCR.
+ :param ocr_metadata: Provider-returned OCR metadata for this page.
+ """
+
+ 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)
+ text: str | None = None
+ provider_name: str | None = None
+ model_name: str | None = None
+ ocr_metadata: dict[str, Any] = field(default_factory=dict)
+
+ @property
+ def width(self) -> int:
+ """Return the current page image width in pixels."""
+ return self.image.width
+
+ @property
+ def height(self) -> int:
+ """Return the current page image height in pixels."""
+ return self.image.height
+
+ @classmethod
+ def from_image(
+ cls,
+ image: Image.Image,
+ *,
+ page_index: int = 0,
+ source_index: int = 0,
+ metadata: dict[str, Any] | None = None,
+ ) -> DocumentPage:
+ """Create a document page from an in-memory image.
+
+ :param image: Source page image.
+ :param page_index: Page position to attach to the page.
+ :param source_index: Source index to attach to the page.
+ :param metadata: Optional caller-side metadata for the page.
+ :returns: New page object with a copied image.
+ """
+ return cls(
+ page_index=page_index,
+ source_index=source_index,
+ image=image.copy(),
+ metadata=dict(metadata or {}),
+ )
+
+ @classmethod
+ def from_image_path(
+ cls,
+ path: str | Path,
+ *,
+ page_index: int = 0,
+ source_index: int = 0,
+ metadata: dict[str, Any] | None = None,
+ ) -> DocumentPage:
+ """Create a document page from an image path.
+
+ :param path: Path to the page image on disk.
+ :param page_index: Page position to attach to the page.
+ :param source_index: Source index to attach to the page.
+ :param metadata: Optional caller-side metadata for the page.
+ :returns: New page object loaded from ``path``.
+ """
+ return cls.from_image(
+ load_image(path),
+ page_index=page_index,
+ source_index=source_index,
+ metadata=metadata,
+ )
+
+ def with_ocr(
+ self,
+ *,
+ text: str,
+ provider_name: str,
+ model_name: str,
+ ocr_metadata: dict[str, Any] | None = None,
+ ) -> DocumentPage:
+ """Return a copy of the page with OCR output attached.
+
+ :param text: OCR text for the page.
+ :param provider_name: Provider identifier to attach.
+ :param model_name: Model name to attach.
+ :param ocr_metadata: Provider-returned OCR metadata.
+ :returns: Copy of the current page with OCR fields filled in.
+ """
+ return replace(
+ self,
+ text=text,
+ provider_name=provider_name,
+ model_name=model_name,
+ ocr_metadata=dict(ocr_metadata or {}),
+ )
+
+
+@dataclass(slots=True)
+class PageDetectionRequest:
+ """Request payload for image page detection.
+
+ :param image: In-memory image to detect pages from. Mutually exclusive with
+ ``image_path``.
+ :param image_path: Path to an image on disk. Mutually exclusive with ``image``.
+ :param trim_margin: Margin in pixels to add around detected crops.
+ """
+
+ image: Image.Image | None = None
+ image_path: str | Path | None = None
+ trim_margin: int = 30
+
+ def require_image(self) -> Image.Image:
+ """Return the input image, loading it from disk when needed.
+
+ :returns: Copy of the requested image.
+ :raises ConfigurationError: If both or neither of ``image`` and
+ ``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`.")
+ 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.")
+
+
+@dataclass(slots=True)
+class PageDetectionResult:
+ """Page detection output for an image or PDF.
+
+ :param pages: Detected pages in output order.
+ :param source_type: Input source type, typically ``"image"`` or ``"pdf"``.
+ :param metadata: Detection-level metadata, such as PDF rasterization settings.
+ """
+
+ pages: list[DocumentPage]
+ source_type: str
+ metadata: dict[str, Any] = field(default_factory=dict)
+
+
+@runtime_checkable
+class PageDetectionBackend(Protocol):
+ """Async interface for page detection."""
+
+ async def detect(self, image: Image.Image) -> list[PageCandidate]:
+ """Detect page candidates from one image.
+
+ :param image: Source image to analyze.
+ :returns: Page candidates in reading order.
+ """
+ ...
+
+
+PageDetectionCallable = Callable[[Image.Image], Awaitable[list[PageCandidate]]]
+PageDetectionBackendLike = PageDetectionBackend | PageDetectionCallable
+
+
+class PageDetector:
+ """Detect one or more page crops from an input image."""
+
+ def __init__(self, backend: PageDetectionBackendLike | None = None) -> None:
+ """Create a page detector.
+
+ :param backend: Optional low-level backend or async callable. When not
+ provided, the full input image is treated as a single page.
+ """
+ self._backend = backend
+
+ async def adetect(self, request: PageDetectionRequest) -> list[DocumentPage]:
+ """Asynchronously detect pages for a single image.
+
+ :param request: Detection request describing the source image.
+ :returns: Detected page crops in reading order.
+ """
+ image = request.require_image()
+ candidates = await self._detect_candidates(image)
+ detected_pages: list[DocumentPage] = []
+ for candidate in candidates:
+ detected_pages.append(
+ DocumentPage(
+ page_index=len(detected_pages),
+ image=self._materialize_candidate(
+ source_image=image,
+ candidate=candidate,
+ trim_margin=request.trim_margin,
+ ),
+ source_index=0,
+ bbox=candidate.bbox,
+ polygon=candidate.polygon,
+ metadata=dict(candidate.metadata),
+ )
+ )
+ return detected_pages
+
+ def detect(self, request: PageDetectionRequest) -> list[DocumentPage]:
+ """Synchronously detect pages for a single image.
+
+ :param request: Detection request describing the source image.
+ :returns: Detected page crops in reading order.
+ """
+ return run_sync(self.adetect(request))
+
+ async def _detect_candidates(self, image: Image.Image) -> list[PageCandidate]:
+ if self._backend is None:
+ return [PageCandidate(bbox=(0.0, 0.0, float(image.width), float(image.height)))]
+ if callable(self._backend) and not isinstance(self._backend, PageDetectionBackend):
+ candidates = await self._backend(image)
+ else:
+ assert isinstance(self._backend, PageDetectionBackend)
+ candidates = await self._backend.detect(image)
+ return candidates or [PageCandidate(bbox=(0.0, 0.0, float(image.width), float(image.height)))]
+
+ def _materialize_candidate(
+ self,
+ *,
+ source_image: Image.Image,
+ candidate: PageCandidate,
+ trim_margin: int,
+ ) -> Image.Image:
+ if candidate.image is not None:
+ return candidate.image.copy()
+ if candidate.polygon:
+ return _crop_polygon(source_image, candidate.polygon, trim_margin=trim_margin)
+ if candidate.bbox is None:
+ return source_image.copy()
+ return _crop_bbox(source_image, candidate.bbox, trim_margin=trim_margin)
+
+
+class DocumentPageDetector:
+ """Detect pages from raw images or PDFs."""
+
+ def __init__(
+ self,
+ *,
+ backend: PageDetectionBackendLike | None = None,
+ ) -> None:
+ """Create a document page detector.
+
+ :param backend: Optional low-level detection backend or async callable.
+ """
+ self._page_detector = PageDetector(backend)
+
+ async def detect_image(self, request: PageDetectionRequest) -> PageDetectionResult:
+ """Detect pages in a single image.
+
+ :param request: Detection request describing the source image.
+ :returns: Detection result for one image input.
+ """
+ pages = await self._page_detector.adetect(request)
+ return PageDetectionResult(pages=pages, source_type="image")
+
+ def detect_image_sync(self, request: PageDetectionRequest) -> PageDetectionResult:
+ """Synchronously detect pages in a single image.
+
+ :param request: Detection request describing the source image.
+ :returns: Detection result for one image input.
+ """
+ return run_sync(self.detect_image(request))
+
+ async def detect_pdf(
+ self,
+ path: str | Path,
+ *,
+ dpi: int = 300,
+ trim_margin: int = 30,
+ ) -> PageDetectionResult:
+ """Rasterize a PDF and detect pages on each image.
+
+ :param path: PDF path to rasterize.
+ :param dpi: Rasterization DPI used before detection.
+ :param trim_margin: Pixel margin added around detected crops.
+ :returns: Detection result containing all detected pages from the PDF.
+ """
+ images = rasterize_pdf(path, dpi=dpi)
+ pages: list[DocumentPage] = []
+ for pdf_index, image in enumerate(images):
+ detected_pages = await self._page_detector.adetect(
+ PageDetectionRequest(image=image, trim_margin=trim_margin)
+ )
+ for page in detected_pages:
+ pages.append(
+ DocumentPage(
+ page_index=len(pages),
+ image=page.image,
+ source_index=pdf_index,
+ bbox=page.bbox,
+ polygon=page.polygon,
+ metadata=dict(page.metadata),
+ )
+ )
+ return PageDetectionResult(
+ pages=pages,
+ source_type="pdf",
+ metadata={"dpi": dpi, "path": str(path)},
+ )
+
+ def detect_pdf_sync(
+ self,
+ path: str | Path,
+ *,
+ dpi: int = 300,
+ trim_margin: int = 30,
+ ) -> PageDetectionResult:
+ """Synchronously rasterize a PDF and detect pages on each image.
+
+ :param path: PDF path to rasterize.
+ :param dpi: Rasterization DPI used before detection.
+ :param trim_margin: Pixel margin added around detected crops.
+ :returns: Detection result containing all detected pages from the PDF.
+ """
+ return run_sync(self.detect_pdf(path, dpi=dpi, trim_margin=trim_margin))
+
+
+def _crop_bbox(
+ source_image: Image.Image,
+ bbox: tuple[float, float, float, float],
+ *,
+ trim_margin: int,
+) -> Image.Image:
+ left, top, right, bottom = bbox
+ expanded_left = max(int(left - trim_margin), 0)
+ expanded_top = max(int(top - trim_margin), 0)
+ expanded_right = min(int(right + trim_margin), source_image.width)
+ expanded_bottom = min(int(bottom + trim_margin), source_image.height)
+ return source_image.crop((expanded_left, expanded_top, expanded_right, expanded_bottom))
+
+
+def _crop_polygon(
+ source_image: Image.Image,
+ polygon: tuple[tuple[float, float], ...],
+ *,
+ trim_margin: int,
+) -> Image.Image:
+ xs = [point[0] for point in polygon]
+ ys = [point[1] for point in polygon]
+ bbox = (min(xs), min(ys), max(xs), max(ys))
+ cropped = _crop_bbox(source_image, bbox, trim_margin=trim_margin)
+ left = max(int(bbox[0] - trim_margin), 0)
+ top = max(int(bbox[1] - trim_margin), 0)
+
+ mask = Image.new("L", cropped.size, 0)
+ relative_points = [(x - left, y - top) for x, y in polygon]
+ ImageDraw.Draw(mask).polygon(relative_points, fill=255)
+
+ background = Image.new(cropped.mode, cropped.size, color="white")
+ background.paste(cropped, mask=mask)
+ return background
diff --git a/src/churro_ocr/prompts/__init__.py b/src/churro_ocr/prompts/__init__.py
new file mode 100644
index 0000000..578679d
--- /dev/null
+++ b/src/churro_ocr/prompts/__init__.py
@@ -0,0 +1,21 @@
+"""Public prompt defaults used by churro-ocr backends."""
+
+from churro_ocr.prompts.layout import (
+ DEFAULT_BOUNDARY_DETECTION_PROMPT,
+)
+from churro_ocr.prompts.ocr import (
+ DEFAULT_MARKDOWN_OCR_USER_PROMPT,
+ DEFAULT_OCR_OUTPUT_TAG,
+ DEFAULT_OCR_SYSTEM_PROMPT,
+ DEFAULT_OCR_USER_PROMPT,
+ strip_ocr_output_tag,
+)
+
+__all__ = [
+ "DEFAULT_BOUNDARY_DETECTION_PROMPT",
+ "DEFAULT_MARKDOWN_OCR_USER_PROMPT",
+ "DEFAULT_OCR_OUTPUT_TAG",
+ "DEFAULT_OCR_SYSTEM_PROMPT",
+ "DEFAULT_OCR_USER_PROMPT",
+ "strip_ocr_output_tag",
+]
diff --git a/src/churro_ocr/prompts/layout.py b/src/churro_ocr/prompts/layout.py
new file mode 100644
index 0000000..e7b5c3f
--- /dev/null
+++ b/src/churro_ocr/prompts/layout.py
@@ -0,0 +1,175 @@
+"""Default prompts used by page-detection backends."""
+
+from __future__ import annotations
+
+import json
+
+PAGE_RESPONSE_INSTRUCTIONS = """- Return a JSON object with a key "pages".
+- "pages" must be a list containing zero or more objects.
+- Each object must have:
+ * "page_index": 1-based index in reading order.
+ * "left": integer normalized 0-1000 describing the minimum horizontal coordinate.
+ * "top": integer normalized 0-1000 describing the minimum vertical coordinate.
+ * "right": integer normalized 0-1000 describing the maximum horizontal coordinate.
+ * "bottom": integer normalized 0-1000 describing the maximum vertical coordinate.
+- Provide all coordinates as integers (no decimals) and keep them normalized to the
+ 0-1000 range.
+- CRITICAL GOAL: Find the **tightest possible bounding box** that contains **ALL**
+ meaningful content on the page.
+- INCLUDE:
+ * All printed text (headers, footers, page numbers, body text).
+ * All handwriting (signatures, marginalia, corrections).
+ * All stamps, seals, logos, and drawings.
+ * Any content that conveys information.
+- EXCLUDE:
+ * Empty page margins (white space).
+ * Content belonging to a different page (even if it appears in the same image).
+ * Partial/overflowing text or graphics from a neighboring page.
+ * Dark edges or background from the scanner/camera.
+ * Binding rings, spiral binding, or book spines.
+ * Shadows, scanner noise, or artifacts outside the content area.
+- The box should be as small as possible while still containing every pixel of ink/content.
+- Each returned box must correspond to exactly one page's content region.
+- If no page is visible, return {"pages": []}.
+"""
+
+DEFAULT_BOUNDARY_DETECTION_PROMPT = (
+ "You are an expert document analysis AI. Your task is to detect the precise boundaries\n"
+ "of document pages to prepare them for OCR.\n"
+ "The goal is to crop the image to the **tightest possible rectangle** that contains all\n"
+ "the content, removing as much empty margin as possible.\n\n"
+ "IMPORTANT TERMINOLOGY: In this task, 'page' means the tightest bounding box around the\n"
+ "visible content on a page, NOT the full sheet of paper. Exclude blank/white paper margins\n"
+ "whenever they do not contain meaningful content.\n\n"
+ "If a neighboring page intrudes into the image (or overlaps visually), do NOT include that\n"
+ "spillover content in this page's box. Each box should isolate one page only.\n\n"
+ "Identify every document page in this image (usually 1 or 2). For each page, define a\n"
+ "bounding box following these strict rules:\n"
+ f"{PAGE_RESPONSE_INSTRUCTIONS}"
+)
+
+
+def build_boundary_review_prompt(
+ *,
+ edge_name: str,
+ page_index: int,
+ strip_axis: str,
+) -> str:
+ """Build the per-edge review prompt used for iterative page-boundary refinement."""
+ return (
+ "You are an expert reviewer of a document page boundary annotation.\n"
+ "You are reviewing ONE EDGE of the red rectangle using an edge strip crop.\n"
+ "The crop is a narrow strip centered on the target edge of the current red boundary.\n\n"
+ "IMPORTANT TERMINOLOGY: 'Page boundary' means the tightest box around page CONTENT\n"
+ "(ink/text/stamps/handwriting), not the full paper edge. Exclude white/blank paper margins\n"
+ "unless they contain meaningful content.\n\n"
+ "CRITICAL ISOLATION RULE: Exclude content from neighboring pages. If this strip shows\n"
+ "partial/overflowing text or graphics from another page crossing near the target edge,\n"
+ "do not expand the target page box to include that other page's content.\n\n"
+ f"Target page_index: {page_index}\n"
+ f"Target edge: {edge_name}\n\n"
+ "Task:\n"
+ f"- Judge ONLY the {edge_name} edge of the red rectangle.\n"
+ "- Decide whether that edge should expand, shrink, or stay unchanged.\n"
+ "- Base the decision on visible content near that edge in this strip crop.\n"
+ "- Keep only the target page's content; exclude neighboring-page spillover.\n"
+ "- Do not make decisions for the other three edges.\n\n"
+ "Output JSON only with this schema:\n"
+ "{\n"
+ ' "page_index": ,\n'
+ f' "edge": "{edge_name}",\n'
+ ' "action": "expand" | "shrink" | "no_change",\n'
+ ' "amount": \n'
+ "}\n\n"
+ "Amount semantics:\n"
+ f"- 'amount' is a normalized 0-1000 delta along the strip's {strip_axis}.\n"
+ "- For 'no_change', return amount = 0.\n"
+ "- Prefer 'no_change' unless there is a clear improvement.\n"
+ "- Do NOT return absolute box coordinates.\n"
+ )
+
+
+def build_text_block_localization_prompt(*, block_tag: str, block_text: str) -> str:
+ """Build the initial prompt for locating a single rendered text block."""
+ target_tag = json.dumps(block_tag, ensure_ascii=False)
+ target_text = json.dumps(block_text, ensure_ascii=False)
+ return (
+ "You are an expert reviewer of historical document layout.\n"
+ "Your task is to find the SINGLE rendered content block in the image that matches the\n"
+ "target HDML block below.\n\n"
+ f"Target block tag (JSON string): {target_tag}\n"
+ f"Target block text (JSON string): {target_text}\n\n"
+ "MATCHING RULES:\n"
+ "- The target may be a heading, paragraph, date line, marginal note, block quotation,\n"
+ " list, table, figure caption, or page number depending on the tag.\n"
+ "- Match the same block even if whitespace, punctuation, line breaks, ligatures, or\n"
+ " historical glyphs differ slightly from the normalized transcription.\n"
+ "- Return one box for the WHOLE target block, not for an individual line, word, cell,\n"
+ " or inline span inside it.\n"
+ "- If multiple visually identical matches exist and you cannot confidently choose one,\n"
+ " return not found.\n\n"
+ "BOUNDING BOX RULES:\n"
+ "- Return the tightest possible bounding box around the entire target block only.\n"
+ "- INCLUDE all ink that belongs to that block.\n"
+ "- For tables, include all cells and ruling lines belonging to the target table.\n"
+ "- For lists, include all items belonging to the target list.\n"
+ "- Exclude neighboring paragraphs, headings, notes, page numbers, tables, and unrelated\n"
+ " marks or decorations.\n\n"
+ "Output JSON only with this schema:\n"
+ "{\n"
+ ' "block_found": ,\n'
+ ' "block": {\n'
+ ' "left": ,\n'
+ ' "top": ,\n'
+ ' "right": ,\n'
+ ' "bottom": \n'
+ " } | null\n"
+ "}\n\n"
+ "- If the target block is absent or cannot be uniquely identified, return "
+ '{"block_found": false, "block": null}.\n'
+ "- Provide all coordinates as integers in the 0-1000 range.\n"
+ "- Do not return any explanation.\n"
+ )
+
+
+def build_text_block_boundary_review_prompt(
+ *,
+ edge_name: str,
+ block_tag: str,
+ block_text: str,
+ strip_axis: str,
+) -> str:
+ """Build the per-edge review prompt used for iterative text-block refinement."""
+ target_tag = json.dumps(block_tag, ensure_ascii=False)
+ target_text = json.dumps(block_text, ensure_ascii=False)
+ return (
+ "You are an expert reviewer of a content-block bounding box annotation.\n"
+ "You are reviewing ONE EDGE of the red rectangle using an edge strip crop.\n"
+ "The crop is a narrow strip centered on the target edge of the current red boundary.\n\n"
+ f"Target block tag (JSON string): {target_tag}\n"
+ f"Target block text (JSON string): {target_text}\n"
+ f"Target edge: {edge_name}\n\n"
+ "CRITICAL GOAL:\n"
+ "- The rectangle must tightly contain only the single target block.\n"
+ "- Include all ink from that block and exclude neighboring blocks.\n"
+ "- For tables, include all table text and ruling lines belonging to that table.\n"
+ "- For lists, include all items belonging to that list.\n"
+ "- Exclude nearby paragraphs, headings, marginal notes, page numbers, stains, borders,\n"
+ " and unrelated annotations.\n\n"
+ "Task:\n"
+ f"- Judge ONLY the {edge_name} edge of the red rectangle.\n"
+ "- Decide whether that edge should expand, shrink, or stay unchanged.\n"
+ "- Base the decision on the target block only.\n"
+ "- Do not make decisions for the other three edges.\n\n"
+ "Output JSON only with this schema:\n"
+ "{\n"
+ f' "edge": "{edge_name}",\n'
+ ' "action": "expand" | "shrink" | "no_change",\n'
+ ' "amount": \n'
+ "}\n\n"
+ "Amount semantics:\n"
+ f"- 'amount' is a normalized 0-1000 delta along the strip's {strip_axis}.\n"
+ "- For 'no_change', return amount = 0.\n"
+ "- Prefer 'no_change' unless there is a clear improvement.\n"
+ "- Do NOT return absolute box coordinates.\n"
+ )
diff --git a/src/churro_ocr/prompts/ocr.py b/src/churro_ocr/prompts/ocr.py
new file mode 100644
index 0000000..b96db4e
--- /dev/null
+++ b/src/churro_ocr/prompts/ocr.py
@@ -0,0 +1,59 @@
+"""Default OCR prompts."""
+
+from __future__ import annotations
+
+import re
+
+DEFAULT_OCR_OUTPUT_TAG = "output"
+
+DEFAULT_OCR_SYSTEM_PROMPT = (
+ "You are an expert in diplomatic transcription of historical documents from various "
+ "languages. Your task is to extract the full text from a given page. Only output the "
+ f"transcribed text between <{DEFAULT_OCR_OUTPUT_TAG}> and {DEFAULT_OCR_OUTPUT_TAG}> tags."
+)
+
+DEFAULT_OCR_USER_PROMPT = (
+ "Follow these instructions:\n\n"
+ "1. You will be provided with a scanned document page.\n\n"
+ "2. Perform transcription on the entirety of the page, converting all visible text into "
+ "the following format. Include handwritten and print text, if any. Include tables, "
+ "captions, headers, main text and all other visible text.\n\n"
+ "3. If you encounter any non-text elements, simply skip them without attempting to "
+ "describe them.\n\n"
+ "4. Do not modernize or standardize the text. For example, if the transcription is using "
+ '"ſ" instead of "s" or "а" instead of "a", keep it that way.\n\n'
+ "5. When you come across text in languages other than English, transcribe it as "
+ "accurately as possible without translation.\n\n"
+ "6. Output the OCR result in the following format:\n\n"
+ f"<{DEFAULT_OCR_OUTPUT_TAG}>\n"
+ "extracted text here\n"
+ f"{DEFAULT_OCR_OUTPUT_TAG}>\n\n"
+ "Remember, your goal is to accurately transcribe the text from the scanned page as much "
+ "as possible. Process the entire page, even if it contains a large amount of text, and "
+ "provide clear, well-formatted output. Pay attention to the appropriate reading order "
+ "and layout of the text."
+)
+
+DEFAULT_MARKDOWN_OCR_USER_PROMPT = (
+ "Transcribe the full page in reading order as Markdown. Preserve headings, lists, "
+ "tables, and line breaks when they are visible."
+)
+
+
+def strip_ocr_output_tag(text: str, *, output_tag: str = DEFAULT_OCR_OUTPUT_TAG) -> str:
+ """Remove outer OCR output tags and any stray tag tokens when present.
+
+ :param text: Raw OCR response text.
+ :param output_tag: Expected wrapper tag name.
+ :returns: OCR text with the outer wrapper removed when present.
+ """
+ outer_wrapper_pattern = re.compile(
+ rf"^\s*<{re.escape(output_tag)}>\s*(.*?)\s*{re.escape(output_tag)}>\s*$",
+ flags=re.DOTALL,
+ )
+ match = outer_wrapper_pattern.match(text)
+ if match is not None:
+ return match.group(1).strip()
+
+ stray_tag_pattern = re.compile(rf"?{re.escape(output_tag)}\b[^>]*>", flags=re.IGNORECASE)
+ return stray_tag_pattern.sub("", text).strip()
diff --git a/src/churro_ocr/providers/__init__.py b/src/churro_ocr/providers/__init__.py
new file mode 100644
index 0000000..ecab2bd
--- /dev/null
+++ b/src/churro_ocr/providers/__init__.py
@@ -0,0 +1,93 @@
+"""Public OCR builders and page detection backends."""
+
+from __future__ import annotations
+
+from importlib import import_module
+from typing import TYPE_CHECKING, Any
+
+from churro_ocr.ocr import BatchOCRBackend
+
+if TYPE_CHECKING:
+ from churro_ocr.providers.builder import build_ocr_backend
+ from churro_ocr.providers.page_detection import (
+ AzurePageDetector,
+ LLMPageDetector,
+ locate_text_block_bbox_with_llm,
+ locate_text_block_bbox_with_llm_sync,
+ )
+ from churro_ocr.providers.specs import (
+ DEFAULT_OCR_MAX_TOKENS,
+ AzureDocumentIntelligenceOptions,
+ HuggingFaceOptions,
+ LiteLLMTransportConfig,
+ MistralOptions,
+ OCRBackendSpec,
+ OCRModelProfile,
+ OpenAICompatibleOptions,
+ VLLMOptions,
+ resolve_ocr_profile,
+ )
+
+
+_LAZY_EXPORTS = {
+ "AzureDocumentIntelligenceOptions": (
+ "churro_ocr.providers.specs",
+ "AzureDocumentIntelligenceOptions",
+ ),
+ "AzurePageDetector": ("churro_ocr.providers.page_detection", "AzurePageDetector"),
+ "build_ocr_backend": ("churro_ocr.providers.builder", "build_ocr_backend"),
+ "DEFAULT_OCR_MAX_TOKENS": ("churro_ocr.providers.specs", "DEFAULT_OCR_MAX_TOKENS"),
+ "HuggingFaceOptions": ("churro_ocr.providers.specs", "HuggingFaceOptions"),
+ "LiteLLMTransportConfig": ("churro_ocr.providers.specs", "LiteLLMTransportConfig"),
+ "LLMPageDetector": ("churro_ocr.providers.page_detection", "LLMPageDetector"),
+ "locate_text_block_bbox_with_llm": (
+ "churro_ocr.providers.page_detection",
+ "locate_text_block_bbox_with_llm",
+ ),
+ "locate_text_block_bbox_with_llm_sync": (
+ "churro_ocr.providers.page_detection",
+ "locate_text_block_bbox_with_llm_sync",
+ ),
+ "MistralOptions": ("churro_ocr.providers.specs", "MistralOptions"),
+ "OCRBackendSpec": ("churro_ocr.providers.specs", "OCRBackendSpec"),
+ "OCRModelProfile": ("churro_ocr.providers.specs", "OCRModelProfile"),
+ "OpenAICompatibleOptions": ("churro_ocr.providers.specs", "OpenAICompatibleOptions"),
+ "resolve_ocr_profile": ("churro_ocr.providers.specs", "resolve_ocr_profile"),
+ "VLLMOptions": ("churro_ocr.providers.specs", "VLLMOptions"),
+}
+
+__all__ = [
+ "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",
+ "MistralOptions",
+ "OCRBackendSpec",
+ "OCRModelProfile",
+ "OpenAICompatibleOptions",
+ "resolve_ocr_profile",
+ "VLLMOptions",
+]
+
+
+def __getattr__(name: str) -> Any:
+ """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
+
+ value = getattr(import_module(module_name), attr_name)
+ globals()[name] = value
+ return value
+
+
+def __dir__() -> list[str]:
+ """Expose the lazy exports to interactive tooling."""
+ return sorted(set(globals()) | set(__all__))
diff --git a/src/churro_ocr/providers/_shared.py b/src/churro_ocr/providers/_shared.py
new file mode 100644
index 0000000..9d72254
--- /dev/null
+++ b/src/churro_ocr/providers/_shared.py
@@ -0,0 +1,93 @@
+"""Internal shared helpers for OCR provider adapters."""
+
+from __future__ import annotations
+
+from dataclasses import replace
+from typing import Any
+
+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,
+)
+
+
+def preprocess_backend_page(
+ page: DocumentPage,
+ *,
+ image_preprocessor: ImagePreprocessor,
+) -> DocumentPage:
+ """Return a page copy with the backend's explicit image preprocessing applied."""
+ return replace(page, image=image_preprocessor(page.image))
+
+
+def render_ocr_prompt(
+ processor: object,
+ template: OCRPromptTemplateLike,
+ page: DocumentPage,
+ *,
+ add_generation_prompt: bool,
+) -> tuple[str, OCRConversation]:
+ """Render a provider prompt and preserve the structured OCR conversation."""
+ conversation = build_ocr_conversation(template, page)
+ processor_apply = getattr(processor, "apply_chat_template", None)
+ if callable(processor_apply):
+ rendered = processor_apply(
+ conversation,
+ add_generation_prompt=add_generation_prompt,
+ tokenize=False,
+ )
+ return rendered, conversation
+
+ tokenizer = getattr(processor, "tokenizer", None)
+ tokenizer_apply = getattr(tokenizer, "apply_chat_template", None)
+ if callable(tokenizer_apply):
+ rendered = tokenizer_apply(
+ conversation,
+ add_generation_prompt=add_generation_prompt,
+ tokenize=False,
+ )
+ return rendered, conversation
+
+ raise ConfigurationError(
+ "OCR prompt rendering requires either `processor.apply_chat_template(...)`, "
+ "or `processor.tokenizer.apply_chat_template(...)`."
+ )
+
+
+def normalize_media_inputs(media_inputs: object | None) -> object | None:
+ """Normalize provider media inputs to list form when needed."""
+ if media_inputs is None:
+ return None
+ if isinstance(media_inputs, (list, tuple)):
+ return media_inputs
+ return [media_inputs]
+
+
+def build_ocr_result(
+ text: str,
+ *,
+ provider_name: str,
+ model_name: str,
+ text_postprocessor: TextPostprocessor,
+ metadata: dict[str, Any] | None = None,
+) -> OCRResult:
+ """Build a normalized OCR result after postprocessing."""
+ return OCRResult(
+ text=text_postprocessor(text),
+ provider_name=provider_name,
+ model_name=model_name,
+ metadata=dict(metadata or {}),
+ )
+
+
+__all__ = [
+ "build_ocr_result",
+ "normalize_media_inputs",
+ "preprocess_backend_page",
+ "render_ocr_prompt",
+]
diff --git a/src/churro_ocr/providers/builder.py b/src/churro_ocr/providers/builder.py
new file mode 100644
index 0000000..7f7550b
--- /dev/null
+++ b/src/churro_ocr/providers/builder.py
@@ -0,0 +1,287 @@
+"""Public OCR backend builder."""
+
+from __future__ import annotations
+
+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 (
+ DotsOCR15OCRBackend,
+ HuggingFaceVisionOCRBackend,
+ _default_dots_ocr_1_5_model_kwargs,
+)
+from churro_ocr.providers.ocr import (
+ AzureDocumentIntelligenceOCRBackend,
+ LiteLLMVisionOCRBackend,
+ MistralOCRBackend,
+ OpenAICompatibleOCRBackend,
+)
+from churro_ocr.providers.specs import (
+ AzureDocumentIntelligenceOptions,
+ HuggingFaceOptions,
+ LiteLLMTransportConfig,
+ MistralOptions,
+ OCRBackendSpec,
+ OCRModelProfile,
+ OpenAICompatibleOptions,
+ VLLMOptions,
+ resolve_ocr_profile,
+)
+from churro_ocr.providers.vllm import VLLMVisionOCRBackend
+
+
+def _merge_mapping(
+ base: dict[str, object],
+ override: dict[str, object],
+) -> dict[str, object]:
+ merged = dict(base)
+ for key, value in override.items():
+ existing = merged.get(key)
+ if isinstance(existing, dict) and isinstance(value, dict):
+ merged[key] = {**existing, **value}
+ continue
+ merged[key] = value
+ return merged
+
+
+def _merge_transport_config(
+ base: LiteLLMTransportConfig,
+ override: LiteLLMTransportConfig | None,
+) -> LiteLLMTransportConfig:
+ if override is None:
+ return LiteLLMTransportConfig(
+ api_base=base.api_base,
+ api_key=base.api_key,
+ api_version=base.api_version,
+ image_detail=base.image_detail,
+ completion_kwargs=dict(base.completion_kwargs),
+ cache_dir=base.cache_dir,
+ )
+ return LiteLLMTransportConfig(
+ api_base=override.api_base if override.api_base is not None else base.api_base,
+ api_key=override.api_key if override.api_key is not None else base.api_key,
+ api_version=override.api_version if override.api_version is not None else base.api_version,
+ image_detail=override.image_detail if override.image_detail is not None else base.image_detail,
+ completion_kwargs=_merge_mapping(base.completion_kwargs, override.completion_kwargs),
+ cache_dir=override.cache_dir if override.cache_dir is not None else base.cache_dir,
+ )
+
+
+def _merge_huggingface_options(
+ base: HuggingFaceOptions,
+ override: HuggingFaceOptions | None,
+) -> HuggingFaceOptions:
+ if override is None:
+ return HuggingFaceOptions(
+ trust_remote_code=base.trust_remote_code,
+ processor_kwargs=dict(base.processor_kwargs),
+ model_kwargs=dict(base.model_kwargs),
+ generation_kwargs=dict(base.generation_kwargs),
+ vision_input_builder=base.vision_input_builder,
+ backend_variant=base.backend_variant,
+ )
+ return HuggingFaceOptions(
+ trust_remote_code=(
+ override.trust_remote_code if override.trust_remote_code is not None else base.trust_remote_code
+ ),
+ processor_kwargs=_merge_mapping(base.processor_kwargs, override.processor_kwargs),
+ model_kwargs=_merge_mapping(base.model_kwargs, override.model_kwargs),
+ generation_kwargs=_merge_mapping(base.generation_kwargs, override.generation_kwargs),
+ vision_input_builder=override.vision_input_builder or base.vision_input_builder,
+ backend_variant=override.backend_variant or base.backend_variant,
+ )
+
+
+def _merge_vllm_options(
+ base: VLLMOptions,
+ override: VLLMOptions | None,
+) -> VLLMOptions:
+ if override is None:
+ return VLLMOptions(
+ trust_remote_code=base.trust_remote_code,
+ processor_kwargs=dict(base.processor_kwargs),
+ llm_kwargs=dict(base.llm_kwargs),
+ sampling_kwargs=dict(base.sampling_kwargs),
+ limit_mm_per_prompt=dict(base.limit_mm_per_prompt),
+ )
+ return VLLMOptions(
+ trust_remote_code=(
+ override.trust_remote_code if override.trust_remote_code is not None else base.trust_remote_code
+ ),
+ processor_kwargs=_merge_mapping(base.processor_kwargs, override.processor_kwargs),
+ llm_kwargs=_merge_mapping(base.llm_kwargs, override.llm_kwargs),
+ sampling_kwargs=_merge_mapping(base.sampling_kwargs, override.sampling_kwargs),
+ limit_mm_per_prompt={
+ **base.limit_mm_per_prompt,
+ **override.limit_mm_per_prompt,
+ },
+ )
+
+
+def _merge_openai_options(
+ override: OpenAICompatibleOptions | None,
+) -> OpenAICompatibleOptions:
+ if override is None:
+ return OpenAICompatibleOptions()
+ return OpenAICompatibleOptions(model_prefix=override.model_prefix)
+
+
+def _ensure_options_type[T](options: object | None, expected: type[T], *, provider: str) -> T | None:
+ if options is None:
+ return None
+ if not isinstance(options, expected):
+ raise ConfigurationError(
+ f"OCR provider '{provider}' requires options of type {expected.__name__}, "
+ f"got {type(options).__name__}."
+ )
+ return options
+
+
+def _resolve_model_name(profile: OCRModelProfile, model: str | None, *, fallback: str) -> str:
+ if profile.display_name is not None:
+ return profile.display_name
+ if model is not None:
+ return model
+ return fallback
+
+
+def _build_litellm_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend:
+ if spec.model is None:
+ raise ConfigurationError("OCR provider 'litellm' requires `model`.")
+ transport_config = _merge_transport_config(profile.transport, spec.transport)
+ return LiteLLMVisionOCRBackend(
+ model=spec.model,
+ template=profile.template,
+ model_name=_resolve_model_name(profile, spec.model, fallback=spec.model),
+ transport=LiteLLMTransport(transport_config),
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+def _build_openai_compatible_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend:
+ if spec.model is None:
+ raise ConfigurationError("OCR provider 'openai-compatible' requires `model`.")
+ 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 or not transport_config.api_key:
+ raise ConfigurationError(
+ "OCR provider 'openai-compatible' requires `transport.api_base` and `transport.api_key`."
+ )
+ return OpenAICompatibleOCRBackend(
+ model=spec.model,
+ model_prefix=options.model_prefix or "openai",
+ model_name=_resolve_model_name(profile, spec.model, fallback=spec.model),
+ template=profile.template,
+ transport=LiteLLMTransport(transport_config),
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+def _build_huggingface_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend:
+ if spec.model is None:
+ raise ConfigurationError("OCR provider 'hf' requires `model`.")
+ options = _merge_huggingface_options(
+ profile.huggingface,
+ _ensure_options_type(spec.options, HuggingFaceOptions, provider=spec.provider),
+ )
+ backend_cls: type[HuggingFaceVisionOCRBackend] = HuggingFaceVisionOCRBackend
+ model_kwargs = dict(options.model_kwargs)
+ if options.backend_variant == "dots-ocr-1.5":
+ backend_cls = DotsOCR15OCRBackend
+ model_kwargs = _merge_mapping(_default_dots_ocr_1_5_model_kwargs(), model_kwargs)
+ return backend_cls(
+ model_id=spec.model,
+ template=profile.template,
+ model_name=_resolve_model_name(profile, spec.model, fallback=spec.model),
+ trust_remote_code=bool(options.trust_remote_code),
+ processor_kwargs=dict(options.processor_kwargs),
+ model_kwargs=model_kwargs,
+ generation_kwargs=dict(options.generation_kwargs),
+ vision_input_builder=options.vision_input_builder,
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+def _build_vllm_backend(spec: OCRBackendSpec, profile: OCRModelProfile) -> OCRBackend:
+ if spec.model is None:
+ raise ConfigurationError("OCR provider 'vllm' requires `model`.")
+ options = _merge_vllm_options(
+ profile.vllm,
+ _ensure_options_type(spec.options, VLLMOptions, provider=spec.provider),
+ )
+ return VLLMVisionOCRBackend(
+ model_id=spec.model,
+ template=profile.template,
+ model_name=_resolve_model_name(profile, spec.model, fallback=spec.model),
+ trust_remote_code=bool(options.trust_remote_code),
+ processor_kwargs=dict(options.processor_kwargs),
+ llm_kwargs=dict(options.llm_kwargs),
+ sampling_kwargs=dict(options.sampling_kwargs),
+ limit_mm_per_prompt=dict(options.limit_mm_per_prompt) or {"image": 1},
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+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=...)."
+ )
+ model_id = spec.model or "prebuilt-layout"
+ return AzureDocumentIntelligenceOCRBackend(
+ endpoint=options.endpoint,
+ api_key=options.api_key,
+ model_id=model_id,
+ model_name=_resolve_model_name(profile, spec.model, fallback=model_id),
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+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=...).")
+ model = spec.model or "mistral-ocr-latest"
+ return MistralOCRBackend(
+ api_key=options.api_key,
+ model=model,
+ model_name=_resolve_model_name(profile, spec.model, fallback=model),
+ image_preprocessor=profile.image_preprocessor,
+ text_postprocessor=profile.text_postprocessor,
+ )
+
+
+def build_ocr_backend(spec: OCRBackendSpec) -> OCRBackend:
+ """Build an OCR backend from a declarative spec.
+
+ :param spec: Declarative backend specification.
+ :returns: Configured OCR backend ready for use with ``OCRClient`` or
+ ``DocumentOCRPipeline``.
+ :raises ConfigurationError: If the provider is unsupported or required
+ provider-specific configuration is missing.
+ """
+ profile = resolve_ocr_profile(model_id=spec.model, profile=spec.profile)
+ if spec.provider == "litellm":
+ return _build_litellm_backend(spec, profile)
+ if spec.provider == "openai-compatible":
+ return _build_openai_compatible_backend(spec, profile)
+ if spec.provider == "hf":
+ return _build_huggingface_backend(spec, profile)
+ if spec.provider == "vllm":
+ return _build_vllm_backend(spec, profile)
+ if spec.provider == "azure":
+ return _build_azure_backend(spec, profile)
+ if spec.provider == "mistral":
+ return _build_mistral_backend(spec, profile)
+ raise ConfigurationError(f"Unsupported OCR provider '{spec.provider}'.")
+
+
+__all__ = ["build_ocr_backend"]
diff --git a/src/churro_ocr/providers/hf.py b/src/churro_ocr/providers/hf.py
new file mode 100644
index 0000000..7832b29
--- /dev/null
+++ b/src/churro_ocr/providers/hf.py
@@ -0,0 +1,487 @@
+"""Hugging Face OCR backends for template-aware multimodal models."""
+
+from __future__ import annotations
+
+import asyncio
+import threading
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+from churro_ocr._internal.prompt_logging import log_prompt_payload_once
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.ocr import OCRBackend, OCRResult
+from churro_ocr.page_detection import DocumentPage
+from churro_ocr.providers._shared import (
+ build_ocr_result,
+ normalize_media_inputs,
+ preprocess_backend_page,
+ render_ocr_prompt,
+)
+from churro_ocr.providers.specs import (
+ DEFAULT_OCR_MAX_TOKENS,
+ ImagePreprocessor,
+ TextPostprocessor,
+ VisionInputBuilder,
+ default_ocr_image_preprocessor,
+ identity_text_postprocessor,
+)
+from churro_ocr.templates import (
+ CHURRO_3B_MODEL_ID,
+ CHURRO_3B_XML_TEMPLATE,
+ DOTS_OCR_1_5_MODEL_ID,
+ DOTS_OCR_1_5_OCR_TEMPLATE,
+ OCRConversation,
+ OCRPromptTemplateLike,
+)
+
+
+@dataclass(slots=True)
+class _HFRuntime:
+ processor_cls: Any
+ model_cls: Any
+ process_vision_info: Any
+
+
+def _load_hf_runtime() -> _HFRuntime:
+ 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(
+ "Hugging Face OCR requires the 'hf' extra. Install with `pip install \"churro-ocr[hf]\"`."
+ ) from exc
+
+ return _HFRuntime(
+ processor_cls=AutoProcessor,
+ model_cls=AutoModelForImageTextToText,
+ process_vision_info=process_vision_info,
+ )
+
+
+def _load_hf_causal_runtime() -> _HFRuntime:
+ 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(
+ "Hugging Face OCR requires the 'hf' extra. Install with `pip install \"churro-ocr[hf]\"`."
+ ) from exc
+
+ return _HFRuntime(
+ processor_cls=AutoProcessor,
+ model_cls=AutoModelForCausalLM,
+ process_vision_info=process_vision_info,
+ )
+
+
+_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) -> str:
+ try:
+ from huggingface_hub import snapshot_download
+ except ImportError as exc: # pragma: no cover - transitively provided by transformers
+ raise ConfigurationError(
+ 'Hugging Face OCR requires huggingface_hub. Install with `pip install "churro-ocr[hf]"`.'
+ ) from exc
+
+ model_dir = (
+ Path.home()
+ / ".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_dots_ocr_vision_module(model_dir)
+ return str(model_dir)
+
+
+def _default_dots_ocr_1_5_model_kwargs() -> dict[str, object]:
+ model_kwargs: dict[str, object] = {"dtype": "auto"}
+ try:
+ import torch
+ except ImportError: # pragma: no cover - torch comes from the hf extra
+ 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
+
+
+@dataclass(slots=True)
+class HuggingFaceVisionOCRBackend(OCRBackend):
+ """OCR backend for local Hugging Face multimodal models with custom templates.
+
+ :param model_id: Hugging Face model identifier.
+ :param template: Prompt template used to render OCR input.
+ :param model_name: Optional human-readable model name for result metadata.
+ :param trust_remote_code: Whether to allow remote model code execution.
+ :param processor_kwargs: Extra kwargs passed to processor loading.
+ :param model_kwargs: Extra kwargs passed to model loading.
+ :param generation_kwargs: Extra generation kwargs passed at inference time.
+ :param vision_input_builder: Optional override for building multimodal inputs.
+ :param image_preprocessor: Image preprocessor applied before OCR.
+ :param text_postprocessor: Text postprocessor applied after OCR.
+ :param provider_name: Provider identifier written into OCR results.
+ """
+
+ model_id: str
+ template: OCRPromptTemplateLike
+ model_name: str | None = None
+ trust_remote_code: bool = False
+ processor_kwargs: dict[str, object] = field(default_factory=dict)
+ model_kwargs: dict[str, object] = field(default_factory=dict)
+ generation_kwargs: dict[str, object] = field(default_factory=dict)
+ vision_input_builder: VisionInputBuilder | None = None
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor
+ text_postprocessor: TextPostprocessor = identity_text_postprocessor
+ provider_name: str = "huggingface-transformers"
+ _processor: object | None = field(default=None, init=False, repr=False)
+ _model: object | None = field(default=None, init=False, repr=False)
+ _model_source: str | None = field(default=None, init=False, repr=False)
+ _init_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
+ _has_logged_prompt: bool = field(default=False, init=False, repr=False)
+ _prompt_log_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ """Apply default generation settings after dataclass initialization."""
+ self.generation_kwargs = {
+ "max_new_tokens": DEFAULT_OCR_MAX_TOKENS,
+ **self.generation_kwargs,
+ }
+
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ """Run OCR for one page.
+
+ :param page: Page to transcribe.
+ :returns: Provider-agnostic OCR result.
+ """
+ return await asyncio.to_thread(self._ocr_sync, page)
+
+ async def ocr_batch(self, pages: list[DocumentPage]) -> list[OCRResult]:
+ """Run OCR for multiple pages in one batch.
+
+ :param pages: Pages to transcribe in batch order.
+ :returns: OCR results in the same order as ``pages``.
+ """
+ return await asyncio.to_thread(self._ocr_batch_sync, pages)
+
+ 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,
+ )
+ image_inputs, video_inputs = self._build_vision_inputs(runtime, conversation)
+ batch_kwargs: dict[str, object] = {
+ "text": [rendered],
+ "images": normalize_media_inputs(image_inputs),
+ "return_tensors": "pt",
+ "padding": True,
+ }
+ normalized_video_inputs = normalize_media_inputs(video_inputs)
+ if normalized_video_inputs is not None:
+ batch_kwargs["videos"] = normalized_video_inputs
+ batch = processor(**batch_kwargs)
+ model_device = getattr(model, "device", None)
+ if hasattr(batch, "to") and model_device is not None:
+ batch = 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] = value.to(dtype=model_dtype)
+
+ generated_ids = model.generate(**batch, **self.generation_kwargs)
+ prompt_length = batch["input_ids"].shape[1]
+ completion_ids = generated_ids[:, prompt_length:]
+ text = processor.batch_decode(
+ completion_ids,
+ skip_special_tokens=True,
+ clean_up_tokenization_spaces=False,
+ )[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)
+ rendered_prompts: list[str] = []
+ image_batch: list[object] = []
+ video_batch: list[object] = []
+ has_videos = False
+
+ 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,
+ )
+ image_inputs, video_inputs = self._build_vision_inputs(runtime, conversation)
+ rendered_prompts.append(rendered)
+ image_batch.append(normalize_media_inputs(image_inputs))
+ normalized_video_inputs = normalize_media_inputs(video_inputs)
+ if normalized_video_inputs is not None:
+ has_videos = True
+ video_batch.append(normalized_video_inputs)
+ if not self._has_logged_prompt:
+ self._log_prompt_payload(
+ rendered_prompt=rendered,
+ conversation=conversation,
+ batch_size=len(pages),
+ )
+
+ batch_kwargs: dict[str, object] = {
+ "text": rendered_prompts,
+ "images": image_batch,
+ "return_tensors": "pt",
+ "padding": True,
+ }
+ if has_videos:
+ batch_kwargs["videos"] = video_batch
+ batch = processor(**batch_kwargs)
+ model_device = getattr(model, "device", None)
+ if hasattr(batch, "to") and model_device is not None:
+ batch = 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] = value.to(dtype=model_dtype)
+
+ generated_ids = model.generate(**batch, **self.generation_kwargs)
+ prompt_lengths = batch["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)
+ ]
+ texts = processor.batch_decode(
+ completion_ids,
+ skip_special_tokens=True,
+ clean_up_tokenization_spaces=False,
+ )
+ 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
+ ]
+
+ def _load_runtime(self) -> _HFRuntime:
+ return _load_hf_runtime()
+
+ def _resolve_model_source(self) -> str:
+ return self.model_id
+
+ def _get_model_source(self) -> str:
+ if self._model_source is None:
+ with self._init_lock:
+ if self._model_source is None:
+ self._model_source = self._resolve_model_source()
+ return self._model_source
+
+ def _build_vision_inputs(
+ self,
+ runtime: _HFRuntime,
+ conversation: OCRConversation,
+ ) -> tuple[object | None, object | None]:
+ if self.vision_input_builder is not None:
+ built_inputs = self.vision_input_builder(conversation)
+ if isinstance(built_inputs, tuple) and len(built_inputs) == 2:
+ return built_inputs[0], built_inputs[1]
+ return built_inputs, None
+ image_inputs, video_inputs, _ = runtime.process_vision_info(
+ conversation,
+ return_video_kwargs=True,
+ return_video_metadata=True,
+ )
+ return image_inputs, video_inputs
+
+ def _get_processor(self, runtime: _HFRuntime) -> Any:
+ if self._processor is None:
+ with self._init_lock:
+ if self._processor is None:
+ self._processor = runtime.processor_cls.from_pretrained(
+ self._get_model_source(),
+ trust_remote_code=self.trust_remote_code,
+ **self.processor_kwargs,
+ )
+ return self._processor
+
+ def _get_model(self, runtime: _HFRuntime) -> Any:
+ if self._model is None:
+ with self._init_lock:
+ if self._model is None:
+ self._model = runtime.model_cls.from_pretrained(
+ self._get_model_source(),
+ trust_remote_code=self.trust_remote_code,
+ **self.model_kwargs,
+ )
+ return self._model
+
+ def _log_prompt_payload(
+ self,
+ *,
+ rendered_prompt: str,
+ conversation: OCRConversation,
+ batch_size: int,
+ ) -> None:
+ log_prompt_payload_once(
+ payload={
+ "batch_size": batch_size,
+ "conversation": conversation,
+ "rendered_prompt": rendered_prompt,
+ },
+ 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),
+ )
+
+
+@dataclass(slots=True)
+class Churro3BOCRBackend(HuggingFaceVisionOCRBackend):
+ """Preset OCR backend for ``stanford-oval/churro-3B``."""
+
+ model_id: str = CHURRO_3B_MODEL_ID
+ template: OCRPromptTemplateLike = CHURRO_3B_XML_TEMPLATE
+ model_name: str | None = "churro-3B"
+
+
+@dataclass(slots=True)
+class DotsOCR15OCRBackend(HuggingFaceVisionOCRBackend):
+ """Preset OCR backend for ``kristaller486/dots.ocr-1.5``.
+
+ :param model_kwargs: Extra kwargs passed to model loading on top of the
+ built-in Dots OCR defaults.
+ """
+
+ model_id: str = DOTS_OCR_1_5_MODEL_ID
+ template: OCRPromptTemplateLike = DOTS_OCR_1_5_OCR_TEMPLATE
+ model_name: str | None = "dots.ocr-1.5"
+ trust_remote_code: bool = True
+ model_kwargs: dict[str, object] = field(default_factory=_default_dots_ocr_1_5_model_kwargs)
+
+ def _load_runtime(self) -> _HFRuntime:
+ return _load_hf_causal_runtime()
+
+ def _resolve_model_source(self) -> str:
+ return _prepare_dots_ocr_model_dir(self.model_id)
+
+ def _get_model(self, runtime: _HFRuntime) -> Any:
+ if self._model is None:
+ with self._init_lock:
+ if self._model is None:
+ from transformers import AutoConfig
+
+ model_source = self._get_model_source()
+ config = AutoConfig.from_pretrained(
+ model_source,
+ trust_remote_code=self.trust_remote_code,
+ )
+ vision_config = getattr(config, "vision_config", None)
+ if isinstance(vision_config, dict):
+ vision_config["attn_implementation"] = "sdpa"
+ elif vision_config is not None:
+ vision_config.attn_implementation = "sdpa"
+ self._model = runtime.model_cls.from_pretrained(
+ model_source,
+ config=config,
+ trust_remote_code=self.trust_remote_code,
+ **self.model_kwargs,
+ )
+ return self._model
+
+
+__all__ = [
+ "Churro3BOCRBackend",
+ "DotsOCR15OCRBackend",
+ "HuggingFaceVisionOCRBackend",
+]
diff --git a/src/churro_ocr/providers/ocr.py b/src/churro_ocr/providers/ocr.py
new file mode 100644
index 0000000..8f58f98
--- /dev/null
+++ b/src/churro_ocr/providers/ocr.py
@@ -0,0 +1,316 @@
+"""Built-in OCR backends."""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+from dataclasses import dataclass, field
+from io import BytesIO
+from threading import Lock
+from typing import Any
+
+from churro_ocr._internal.image import image_to_base64
+from churro_ocr._internal.litellm import LiteLLMTransport
+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._shared import build_ocr_result, preprocess_backend_page
+from churro_ocr.providers.specs import (
+ DEFAULT_OCR_MAX_TOKENS,
+ ImagePreprocessor,
+ LiteLLMTransportConfig,
+ TextPostprocessor,
+ default_ocr_image_preprocessor,
+ identity_text_postprocessor,
+)
+from churro_ocr.templates import (
+ DEFAULT_OCR_TEMPLATE,
+ OCRPromptTemplateLike,
+ build_ocr_conversation,
+)
+
+
+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)
+ if completion_kwargs == config.completion_kwargs:
+ return config
+ 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,
+ )
+
+
+@dataclass(slots=True)
+class LiteLLMVisionOCRBackend(OCRBackend):
+ """OCR backend for any LiteLLM-supported multimodal provider.
+
+ :param model: LiteLLM model identifier to call.
+ :param template: Prompt template used to render OCR input.
+ :param model_name: Optional human-readable model name for result metadata.
+ :param transport: LiteLLM transport used for request execution.
+ :param image_preprocessor: Image preprocessor applied before OCR.
+ :param text_postprocessor: Text postprocessor applied after OCR.
+ :param provider_name: Provider identifier written into OCR results.
+ """
+
+ model: str
+ template: OCRPromptTemplateLike = DEFAULT_OCR_TEMPLATE
+ model_name: str | None = None
+ transport: LiteLLMTransport = field(default_factory=LiteLLMTransport)
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor
+ text_postprocessor: TextPostprocessor = identity_text_postprocessor
+ provider_name: str = "litellm"
+ _has_logged_prompt: bool = field(default=False, init=False, repr=False)
+ _prompt_log_lock: Lock = field(default_factory=Lock, init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ """Apply default OCR completion settings to the shared transport."""
+ config = _with_default_ocr_completion_kwargs(self.transport.config)
+ if config != self.transport.config:
+ object.__setattr__(self.transport, "_config", config)
+
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ """Run OCR for one page through LiteLLM.
+
+ :param page: Page to transcribe.
+ :returns: Provider-agnostic OCR result.
+ """
+ prepared_page = preprocess_backend_page(
+ page,
+ image_preprocessor=self.image_preprocessor,
+ )
+ conversation = build_ocr_conversation(self.template, prepared_page)
+ messages = await asyncio.to_thread(
+ self.transport.prepare_messages_from_conversation,
+ conversation,
+ )
+ log_prompt_payload_once(
+ payload={"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 self.transport.complete_text(
+ model=self.model,
+ messages=messages,
+ )
+ return build_ocr_result(
+ text,
+ provider_name=self.provider_name,
+ model_name=self.model_name or self.model,
+ text_postprocessor=self.text_postprocessor,
+ )
+
+
+@dataclass(slots=True, init=False)
+class OpenAICompatibleOCRBackend(LiteLLMVisionOCRBackend):
+ """OCR backend for OpenAI-compatible servers."""
+
+ def __init__(
+ self,
+ *,
+ model: str,
+ transport: LiteLLMTransport | None = None,
+ model_prefix: str = "openai",
+ template: OCRPromptTemplateLike = DEFAULT_OCR_TEMPLATE,
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor,
+ text_postprocessor: TextPostprocessor = identity_text_postprocessor,
+ model_name: str | None = None,
+ ) -> None:
+ """Create an OCR backend for an OpenAI-compatible server.
+
+ :param model: Model identifier exposed by the target server.
+ :param transport: LiteLLM transport used for request execution.
+ :param model_prefix: LiteLLM provider prefix prepended to ``model``.
+ :param template: Prompt template used to render OCR input.
+ :param image_preprocessor: Image preprocessor applied before OCR.
+ :param text_postprocessor: Text postprocessor applied after OCR.
+ :param model_name: Optional human-readable model name for result metadata.
+ """
+ LiteLLMVisionOCRBackend.__init__(
+ self,
+ model=f"{model_prefix}/{model}",
+ template=template,
+ model_name=model_name or model,
+ transport=transport or LiteLLMTransport(),
+ image_preprocessor=image_preprocessor,
+ text_postprocessor=text_postprocessor,
+ provider_name="openai-compatible",
+ )
+
+
+@dataclass(slots=True)
+class AzureDocumentIntelligenceOCRBackend(OCRBackend):
+ """Azure Document Intelligence OCR backend.
+
+ :param endpoint: Azure Document Intelligence endpoint URL.
+ :param api_key: Azure API key for the configured resource.
+ :param model_id: Azure model ID used for OCR.
+ :param model_name: Optional human-readable model name for result metadata.
+ :param image_preprocessor: Image preprocessor applied before OCR.
+ :param text_postprocessor: Text postprocessor applied after OCR.
+ """
+
+ endpoint: str
+ api_key: str
+ model_id: str = "prebuilt-layout"
+ 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_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:
+ client = self._client
+ if client is not None:
+ return client
+
+ async with self._client_lock:
+ client = self._client
+ if client is not None:
+ return client
+ try:
+ 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(
+ "Azure OCR requires the 'azure' extra. Install with `pip install \"churro-ocr[azure]\"`."
+ ) from exc
+
+ client = DocumentIntelligenceClient(
+ endpoint=self.endpoint,
+ credential=AzureKeyCredential(self.api_key),
+ )
+ self._client = client
+ return client
+
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ """Run OCR for one page through Azure Document Intelligence.
+
+ :param page: Page to transcribe.
+ :returns: Provider-agnostic OCR result.
+ :raises ConfigurationError: If the optional Azure dependency is not installed.
+ :raises ProviderError: If Azure returns no text content.
+ """
+ prepared_page = preprocess_backend_page(
+ page,
+ image_preprocessor=self.image_preprocessor,
+ )
+ buffer = (await asyncio.to_thread(image_to_base64, prepared_page.image, "JPEG"))[1]
+ image_bytes = base64.b64decode(buffer)
+ log_prompt_payload_once(
+ payload={
+ "model_id": self.model_id,
+ "content_type": "application/octet-stream",
+ "document": {"type": "image", "image": prepared_page.image},
+ },
+ provider_name="azure-document-intelligence",
+ has_logged=lambda: self._has_logged_prompt,
+ lock=self._prompt_log_lock,
+ set_logged=lambda: setattr(self, "_has_logged_prompt", True),
+ )
+ client = await self._get_client()
+ poller = await client.begin_analyze_document(
+ model_id=self.model_id,
+ body=BytesIO(image_bytes),
+ content_type="application/octet-stream",
+ )
+ result = await poller.result()
+ if not isinstance(result.content, str):
+ raise ProviderError("Azure Document Intelligence returned no OCR text.")
+ return build_ocr_result(
+ result.content,
+ provider_name="azure-document-intelligence",
+ model_name=self.model_name or self.model_id,
+ text_postprocessor=self.text_postprocessor,
+ )
+
+
+@dataclass(slots=True)
+class MistralOCRBackend(OCRBackend):
+ """Mistral OCR backend.
+
+ :param api_key: Mistral API key used for OCR requests.
+ :param model: Mistral OCR model identifier.
+ :param model_name: Optional human-readable model name for result metadata.
+ :param image_preprocessor: Image preprocessor applied before OCR.
+ :param text_postprocessor: Text postprocessor applied after OCR.
+ """
+
+ api_key: str
+ model: str = "mistral-ocr-latest"
+ 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_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:
+ client = self._client
+ if client is not None:
+ return client
+
+ async with self._client_lock:
+ client = self._client
+ if client is not None:
+ return client
+ try:
+ from mistralai import Mistral
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ "Mistral OCR requires the 'mistral' extra. "
+ 'Install with `pip install "churro-ocr[mistral]"`.'
+ ) from exc
+
+ client = Mistral(api_key=self.api_key)
+ self._client = client
+ return client
+
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ """Run OCR for one page through Mistral OCR.
+
+ :param page: Page to transcribe.
+ :returns: Provider-agnostic OCR result.
+ :raises ConfigurationError: If the optional Mistral dependency is not installed.
+ :raises ProviderError: If the response does not contain any OCR pages.
+ """
+ prepared_page = preprocess_backend_page(
+ page,
+ image_preprocessor=self.image_preprocessor,
+ )
+ _, encoded = await asyncio.to_thread(image_to_base64, prepared_page.image, "JPEG")
+ image_url = f"data:image/jpeg;base64,{encoded}"
+ log_prompt_payload_once(
+ payload={
+ "model": self.model,
+ "document": {"type": "image_url", "image_url": image_url},
+ },
+ provider_name="mistral",
+ has_logged=lambda: self._has_logged_prompt,
+ lock=self._prompt_log_lock,
+ set_logged=lambda: setattr(self, "_has_logged_prompt", True),
+ )
+ client = await self._get_client()
+ response = await client.ocr.process_async(
+ model=self.model,
+ document={"type": "image_url", "image_url": image_url},
+ )
+ if not response.pages:
+ raise ProviderError("Mistral OCR returned no pages.")
+ return build_ocr_result(
+ response.pages[0].markdown,
+ provider_name="mistral",
+ model_name=self.model_name or self.model,
+ text_postprocessor=self.text_postprocessor,
+ )
diff --git a/src/churro_ocr/providers/page_detection.py b/src/churro_ocr/providers/page_detection.py
new file mode 100644
index 0000000..b967e4f
--- /dev/null
+++ b/src/churro_ocr/providers/page_detection.py
@@ -0,0 +1,1521 @@
+"""Built-in page detection backends."""
+
+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 churro_ocr._internal.litellm import LiteLLMTransport
+from churro_ocr._internal.logging import logger
+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 (
+ build_boundary_review_prompt,
+ build_text_block_boundary_review_prompt,
+ build_text_block_localization_prompt,
+)
+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")
+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,
+ image: Image.Image,
+ system_prompt: str,
+ user_prompt: str | None,
+ transport: LiteLLMTransport,
+) -> list[_PageBox]:
+ messages = transport.prepare_messages(
+ system_prompt=None,
+ user_prompt=_merge_instruction_prompts(system_prompt, user_prompt),
+ images=[image],
+ )
+ output = await transport.complete_text(
+ model=model,
+ messages=messages,
+ output_json=True,
+ )
+ logger.info("Initial LLM page-detection response: %s", output)
+ return _parse_page_boxes_json(output)
+
+
+async def _complete_text_block_box(
+ *,
+ model: str,
+ image: Image.Image,
+ block_tag: str,
+ block_text: str,
+ transport: LiteLLMTransport,
+) -> _PageBox | None:
+ messages = transport.prepare_messages(
+ system_prompt=None,
+ user_prompt=build_text_block_localization_prompt(
+ block_tag=block_tag,
+ block_text=block_text,
+ ),
+ images=[image],
+ )
+ output = await transport.complete_text(
+ model=model,
+ messages=messages,
+ output_json=True,
+ )
+ logger.info("Initial LLM text-block-localization response: %s", output)
+ return _parse_text_block_box_json(output)
+
+
+async def _review_single_edge_from_strip(
+ *,
+ model: str,
+ review_image: Image.Image,
+ strip_image: Image.Image,
+ strip_bounds: tuple[int, int, int, int],
+ edge_name: str,
+ page_index: int,
+ history_steps: int,
+ round_index: int,
+ transport: LiteLLMTransport,
+) -> _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}'.")
+
+ prompt = build_boundary_review_prompt(
+ edge_name=edge_name,
+ page_index=page_index,
+ strip_axis=(
+ "horizontal (x-axis / strip width)"
+ if edge_name in {"left", "right"}
+ else "vertical (y-axis / strip height)"
+ ),
+ )
+ messages = transport.prepare_messages(
+ system_prompt=None,
+ user_prompt=prompt,
+ images=[strip_image],
+ )
+ output = await transport.complete_text(
+ model=model,
+ messages=messages,
+ output_json=True,
+ )
+ logger.info(
+ "Review LLM single-edge response (round=%s, page=%s, edge=%s, history rounds=%s): %s",
+ round_index,
+ page_index,
+ edge_name,
+ history_steps,
+ output,
+ )
+ response_page_index, response_edge_name, strip_decision = _parse_single_edge_review_decision_json(output)
+ if response_page_index != page_index:
+ logger.info(
+ "Single-edge review page_index mismatch (expected=%s, got=%s) for edge=%s; using expected.",
+ page_index,
+ response_page_index,
+ edge_name,
+ )
+ if response_edge_name != edge_name:
+ logger.info(
+ "Single-edge review edge mismatch (expected=%s, got=%s); using expected edge.",
+ edge_name,
+ response_edge_name,
+ )
+
+ local_axis_pixels = review_image.width if edge_name in {"left", "right"} else review_image.height
+ local_amount = _convert_strip_delta_to_local_delta(
+ strip_decision.amount,
+ strip_axis_pixels=strip_axis_pixels,
+ local_axis_pixels=local_axis_pixels,
+ )
+ return _EdgeReviewDecision(action=strip_decision.action, amount=local_amount)
+
+
+async def _review_single_text_block_edge_from_strip(
+ *,
+ model: str,
+ review_image: Image.Image,
+ strip_image: Image.Image,
+ strip_bounds: tuple[int, int, int, int],
+ edge_name: str,
+ block_tag: str,
+ block_text: str,
+ history_steps: int,
+ round_index: int,
+ transport: LiteLLMTransport,
+) -> _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}'.")
+
+ prompt = build_text_block_boundary_review_prompt(
+ edge_name=edge_name,
+ block_tag=block_tag,
+ block_text=block_text,
+ strip_axis=(
+ "horizontal (x-axis / strip width)"
+ if edge_name in {"left", "right"}
+ else "vertical (y-axis / strip height)"
+ ),
+ )
+ messages = transport.prepare_messages(
+ system_prompt=None,
+ user_prompt=prompt,
+ images=[strip_image],
+ )
+ output = await transport.complete_text(
+ model=model,
+ messages=messages,
+ output_json=True,
+ )
+ logger.info(
+ "Text-block review LLM single-edge response (round=%s, edge=%s, history rounds=%s): %s",
+ round_index,
+ edge_name,
+ history_steps,
+ output,
+ )
+ response_edge_name, strip_decision = _parse_text_block_edge_review_decision_json(output)
+ if response_edge_name != edge_name:
+ logger.info(
+ "Text-block edge-review mismatch (expected=%s, got=%s); using expected edge.",
+ edge_name,
+ response_edge_name,
+ )
+
+ local_axis_pixels = review_image.width if edge_name in {"left", "right"} else review_image.height
+ local_amount = _convert_strip_delta_to_local_delta(
+ strip_decision.amount,
+ strip_axis_pixels=strip_axis_pixels,
+ local_axis_pixels=local_axis_pixels,
+ )
+ return _EdgeReviewDecision(action=strip_decision.action, amount=local_amount)
+
+
+async def _review_page_box(
+ *,
+ image: Image.Image,
+ current_box: _PageBox,
+ history_steps: int,
+ round_index: int,
+ model: str,
+ transport: LiteLLMTransport,
+) -> _PageBox:
+ review_image, crop_bounds = _build_box_review_preview(image, current_box)
+ local_box = _convert_source_box_to_review_crop_box(current_box, crop_bounds, image.size)
+ edge_strip_inputs = [
+ (edge_name, *_build_edge_strip_review_preview(review_image, local_box, edge_name))
+ for edge_name in _EDGE_NAMES
+ ]
+ edge_results = await asyncio.gather(
+ *[
+ _review_single_edge_from_strip(
+ model=model,
+ review_image=review_image,
+ strip_image=strip_image,
+ strip_bounds=strip_bounds,
+ edge_name=edge_name,
+ page_index=current_box.page_index,
+ history_steps=history_steps,
+ round_index=round_index,
+ transport=transport,
+ )
+ for edge_name, strip_image, strip_bounds in edge_strip_inputs
+ ],
+ return_exceptions=True,
+ )
+
+ edge_decisions: dict[str, _EdgeReviewDecision] = {}
+ for edge_name, result in zip(_EDGE_NAMES, edge_results, strict=False):
+ if isinstance(result, Exception):
+ logger.info(
+ "Edge-strip review failed for round %s, page %s, edge %s; using no_change: %s",
+ round_index,
+ current_box.page_index,
+ edge_name,
+ result,
+ )
+ edge_decisions[edge_name] = _no_change_edge_review_decision()
+ continue
+ edge_decisions[edge_name] = result
+
+ reviewed_local_box = _apply_box_review_decision(
+ local_box,
+ _BoxReviewDecision(
+ page_index=current_box.page_index,
+ left=edge_decisions["left"],
+ top=edge_decisions["top"],
+ right=edge_decisions["right"],
+ bottom=edge_decisions["bottom"],
+ ),
+ expected_page_index=current_box.page_index,
+ )
+ return _map_review_crop_box_to_source_box(
+ reviewed_local_box,
+ crop_bounds,
+ image.size,
+ page_index=current_box.page_index,
+ )
+
+
+async def _review_text_block_box(
+ *,
+ image: Image.Image,
+ current_box: _PageBox,
+ block_tag: str,
+ block_text: str,
+ history_steps: int,
+ round_index: int,
+ model: str,
+ transport: LiteLLMTransport,
+) -> _PageBox:
+ review_image, crop_bounds = _build_box_review_preview(
+ image,
+ current_box,
+ margin_fraction=_TEXT_BLOCK_REVIEW_CROP_MARGIN_FRACTION,
+ outline_width=_TEXT_BLOCK_DETECTION_BOX_WIDTH,
+ )
+ local_box = _convert_source_box_to_review_crop_box(current_box, crop_bounds, image.size)
+ edge_strip_inputs = [
+ (
+ edge_name,
+ *_build_edge_strip_review_preview(
+ review_image,
+ local_box,
+ edge_name,
+ outline_width=_TEXT_BLOCK_DETECTION_BOX_WIDTH,
+ ),
+ )
+ for edge_name in _EDGE_NAMES
+ ]
+ edge_results = await asyncio.gather(
+ *[
+ _review_single_text_block_edge_from_strip(
+ model=model,
+ review_image=review_image,
+ strip_image=strip_image,
+ strip_bounds=strip_bounds,
+ edge_name=edge_name,
+ block_tag=block_tag,
+ block_text=block_text,
+ history_steps=history_steps,
+ round_index=round_index,
+ transport=transport,
+ )
+ for edge_name, strip_image, strip_bounds in edge_strip_inputs
+ ],
+ return_exceptions=True,
+ )
+
+ edge_decisions: dict[str, _EdgeReviewDecision] = {}
+ for edge_name, result in zip(_EDGE_NAMES, edge_results, strict=False):
+ if isinstance(result, Exception):
+ logger.info(
+ "Text-block edge-strip review failed for round %s, edge %s; using no_change: %s",
+ round_index,
+ edge_name,
+ result,
+ )
+ edge_decisions[edge_name] = _no_change_edge_review_decision()
+ continue
+ edge_decisions[edge_name] = result
+
+ reviewed_local_box = _apply_box_review_decision(
+ local_box,
+ _BoxReviewDecision(
+ page_index=current_box.page_index,
+ left=edge_decisions["left"],
+ top=edge_decisions["top"],
+ right=edge_decisions["right"],
+ bottom=edge_decisions["bottom"],
+ ),
+ expected_page_index=current_box.page_index,
+ )
+ return _map_review_crop_box_to_source_box(
+ reviewed_local_box,
+ crop_bounds,
+ image.size,
+ page_index=current_box.page_index,
+ )
+
+
+async def _run_review_pipeline(
+ *,
+ initial_boxes: list[_PageBox],
+ max_review_rounds: int,
+ review_box: Callable[[_PageBox, int, int], Awaitable[_PageBox]],
+ subject_name_singular: str,
+ subject_name_plural: str,
+) -> list[_PageBox]:
+ history_boxes: list[list[_PageBox]] = [initial_boxes]
+ page_review_states = {box.page_index: _new_page_review_stop_state() for box in initial_boxes}
+ final_boxes = initial_boxes
+
+ for round_index in range(max(0, max_review_rounds)):
+ previous_boxes = history_boxes[-1]
+ active_boxes = [
+ box
+ for box in previous_boxes
+ if not _page_review_is_fully_frozen(
+ page_review_states.setdefault(box.page_index, _new_page_review_stop_state())
+ )
+ ]
+ if not active_boxes:
+ logger.info(
+ "All %s frozen by review stop condition before round %s; stopping reviews.",
+ subject_name_plural,
+ round_index + 1,
+ )
+ final_boxes = previous_boxes
+ break
+
+ review_results = await asyncio.gather(
+ *(review_box(box, len(history_boxes), round_index + 1) for box in active_boxes),
+ return_exceptions=True,
+ )
+ results_by_page = {
+ box.page_index: result for box, result in zip(active_boxes, review_results, strict=False)
+ }
+
+ reviewed_boxes: list[_PageBox] = []
+ for prior_box in previous_boxes:
+ page_state = page_review_states.setdefault(
+ prior_box.page_index,
+ _new_page_review_stop_state(),
+ )
+ if _page_review_is_fully_frozen(page_state):
+ reviewed_boxes.append(prior_box)
+ continue
+
+ result = results_by_page.get(prior_box.page_index)
+ if isinstance(result, Exception):
+ logger.info(
+ "Review round %s %s %s failed, keeping prior box: %s",
+ round_index + 1,
+ subject_name_singular,
+ prior_box.page_index,
+ result,
+ )
+ reviewed_boxes.append(prior_box)
+ continue
+ if result is None:
+ reviewed_boxes.append(prior_box)
+ continue
+
+ reviewed_boxes.append(
+ _apply_page_review_stop_condition(
+ prior_box=prior_box,
+ reviewed_box=result,
+ page_state=page_state,
+ round_index=round_index + 1,
+ subject_name=subject_name_singular,
+ )
+ )
+
+ reviewed_boxes = sorted(reviewed_boxes, key=lambda item: item.page_index)
+ if not reviewed_boxes:
+ break
+ if _boxes_equal(reviewed_boxes, previous_boxes):
+ final_boxes = reviewed_boxes
+ break
+
+ history_boxes.append(reviewed_boxes)
+ final_boxes = reviewed_boxes
+
+ _log_box_history(history_boxes, subject_name=subject_name_singular.title())
+ return final_boxes
+
+
+@dataclass(slots=True)
+class LLMPageDetector(PageDetectionBackend):
+ """Detect one or more pages via a multimodal LLM prompt.
+
+ :param model: Multimodal model identifier to query through LiteLLM.
+ :param system_prompt: System prompt used for the initial page-box request.
+ :param prompt_template: Optional user prompt override for the initial request.
+ :param transport: Optional LiteLLM transport config.
+ :param max_review_rounds: Number of iterative review rounds used to refine
+ the initial page boxes.
+ """
+
+ model: str
+ system_prompt: str = DEFAULT_BOUNDARY_DETECTION_PROMPT
+ prompt_template: str | None = None
+ transport: LiteLLMTransportConfig | None = None
+ max_review_rounds: int = 0
+
+ async def detect(self, image: Image.Image) -> list[PageCandidate]:
+ """Detect page candidates from one image.
+
+ :param image: Source image that may contain one or more visible pages.
+ :returns: Detected page candidates in reading order. Falls back to a
+ single full-image candidate when no page boxes are returned.
+ """
+ processed_image, transform = _prepare_detection_image(image)
+ transport = LiteLLMTransport(self.transport)
+ boxes = await _complete_page_boxes(
+ model=self.model,
+ image=processed_image,
+ system_prompt=self.system_prompt,
+ user_prompt=self.prompt_template,
+ transport=transport,
+ )
+ if not boxes:
+ return [_full_image_candidate(image)]
+ if self.max_review_rounds > 0:
+
+ async def _review_page_candidate(
+ box: _PageBox,
+ history_steps: int,
+ round_index: int,
+ ) -> _PageBox:
+ return await _review_page_box(
+ image=processed_image,
+ current_box=box,
+ history_steps=history_steps,
+ round_index=round_index,
+ model=self.model,
+ transport=transport,
+ )
+
+ boxes = await _run_review_pipeline(
+ initial_boxes=boxes,
+ max_review_rounds=self.max_review_rounds,
+ review_box=_review_page_candidate,
+ subject_name_singular="page",
+ subject_name_plural="pages",
+ )
+
+ candidates: list[PageCandidate] = []
+ for page_index, box in enumerate(boxes):
+ original_bbox = transform.map_box_to_original(box)
+ candidates.append(
+ PageCandidate(
+ bbox=original_bbox,
+ polygon=_bbox_to_polygon(original_bbox),
+ metadata={
+ "page_index": page_index,
+ "detector": "llm",
+ "response_page_index": box.page_index,
+ },
+ )
+ )
+ return candidates or [_full_image_candidate(image)]
+
+
+async def locate_text_block_bbox_with_llm(
+ image: Image.Image,
+ block_text: str,
+ *,
+ block_tag: str,
+ model: str,
+ transport: LiteLLMTransportLike = None,
+ max_review_rounds: int = 0,
+) -> tuple[float, float, float, float] | None:
+ """Locate the tight bbox of a specific rendered text block via a multimodal LLM.
+
+ :param image: Source page image containing the rendered block.
+ :param block_text: Normalized text content of the target block.
+ :param block_tag: HDML-style block tag describing the block type.
+ :param model: Multimodal model identifier to query through LiteLLM.
+ :param transport: Optional LiteLLM transport or transport config.
+ :param max_review_rounds: Number of iterative review rounds used to refine
+ the initial box.
+ :returns: Bounding box in source-image coordinates, or ``None`` when no
+ unique matching block can be found.
+ :raises ValueError: If ``block_text`` or ``block_tag`` is blank.
+ """
+ normalized_block_text = block_text.strip()
+ if not normalized_block_text:
+ raise ValueError("block_text must not be blank.")
+
+ normalized_block_tag = block_tag.strip()
+ if not normalized_block_tag:
+ raise ValueError("block_tag must not be blank.")
+
+ processed_image, transform = _prepare_detection_image(image)
+ llm_transport = transport if isinstance(transport, LiteLLMTransport) else LiteLLMTransport(transport)
+ box = await _complete_text_block_box(
+ model=model,
+ image=processed_image,
+ block_tag=normalized_block_tag,
+ block_text=normalized_block_text,
+ transport=llm_transport,
+ )
+ if box is None:
+ logger.info(
+ "LLM text-block localization did not find a match for tag=%s.",
+ normalized_block_tag,
+ )
+ return None
+
+ if max_review_rounds > 0:
+
+ async def _review_text_block_candidate(
+ review_box: _PageBox,
+ history_steps: int,
+ round_index: int,
+ ) -> _PageBox:
+ return await _review_text_block_box(
+ image=processed_image,
+ current_box=review_box,
+ block_tag=normalized_block_tag,
+ block_text=normalized_block_text,
+ history_steps=history_steps,
+ round_index=round_index,
+ model=model,
+ transport=llm_transport,
+ )
+
+ reviewed_boxes = await _run_review_pipeline(
+ initial_boxes=[box],
+ max_review_rounds=max_review_rounds,
+ review_box=_review_text_block_candidate,
+ subject_name_singular="text block",
+ subject_name_plural="text blocks",
+ )
+ if not reviewed_boxes:
+ return None
+ box = reviewed_boxes[0]
+
+ return transform.map_box_to_original(box)
+
+
+def locate_text_block_bbox_with_llm_sync(
+ image: Image.Image,
+ block_text: str,
+ *,
+ block_tag: str,
+ model: str,
+ transport: LiteLLMTransportLike = None,
+ max_review_rounds: int = 0,
+) -> tuple[float, float, float, float] | 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.
+ :param block_text: Normalized text content of the target block.
+ :param block_tag: HDML-style block tag describing the block type.
+ :param model: Multimodal model identifier to query through LiteLLM.
+ :param transport: Optional LiteLLM transport or transport config.
+ :param max_review_rounds: Number of iterative review rounds used to refine
+ the initial box.
+ :returns: Bounding box in source-image coordinates, or ``None`` when no
+ unique matching block can be found.
+ :raises ValueError: If ``block_text`` or ``block_tag`` is blank.
+ """
+ return run_sync(
+ locate_text_block_bbox_with_llm(
+ image,
+ block_text,
+ block_tag=block_tag,
+ model=model,
+ transport=transport,
+ max_review_rounds=max_review_rounds,
+ )
+ )
+
+
+@dataclass(slots=True)
+class AzurePageDetector(PageDetectionBackend):
+ """Detect pages from Azure Document Intelligence page output.
+
+ :param endpoint: Azure Document Intelligence endpoint URL.
+ :param api_key: Azure API key for the configured resource.
+ :param model_id: Azure model ID used for page analysis.
+ """
+
+ endpoint: str
+ api_key: str
+ model_id: str = "prebuilt-layout"
+
+ async def detect(self, image: Image.Image) -> list[PageCandidate]:
+ """Detect page candidates from one image using Azure.
+
+ :param image: Source image to analyze.
+ :returns: Detected page candidates in reading order. Falls back to a
+ single full-image candidate when Azure returns no pages.
+ :raises ConfigurationError: If the optional Azure dependency is not installed.
+ """
+ try:
+ 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(
+ "Azure page detection requires the 'azure' extra. "
+ 'Install with `pip install "churro-ocr[azure]"`.'
+ ) from exc
+
+ buffer = BytesIO()
+ image.convert("RGB").save(buffer, format="JPEG")
+ client = DocumentIntelligenceClient(
+ endpoint=self.endpoint,
+ credential=AzureKeyCredential(self.api_key),
+ )
+ try:
+ poller = await client.begin_analyze_document(
+ model_id=self.model_id,
+ body=BytesIO(buffer.getvalue()),
+ content_type="application/octet-stream",
+ )
+ result = await poller.result()
+ finally:
+ await client.close()
+
+ candidates: list[PageCandidate] = []
+ for page_index, page in enumerate(result.pages or []):
+ polygon = _normalize_azure_page_polygon(page, image=image)
+ bbox = _bbox_from_polygon(polygon) if polygon else None
+ metadata = {
+ "page_index": page_index,
+ "page_number": getattr(page, "page_number", page_index + 1),
+ "detector": "azure",
+ }
+ unit = getattr(page, "unit", None)
+ if unit is not None:
+ metadata["unit"] = str(unit)
+ angle = getattr(page, "angle", None)
+ if angle is not None:
+ metadata["angle"] = float(angle)
+ candidates.append(PageCandidate(bbox=bbox, polygon=polygon, metadata=metadata))
+ 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",
+ "locate_text_block_bbox_with_llm",
+ "locate_text_block_bbox_with_llm_sync",
+]
diff --git a/src/churro_ocr/providers/specs.py b/src/churro_ocr/providers/specs.py
new file mode 100644
index 0000000..2ca709d
--- /dev/null
+++ b/src/churro_ocr/providers/specs.py
@@ -0,0 +1,301 @@
+"""Public OCR provider specs, options, and model profile resolution."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import TYPE_CHECKING, Literal
+
+from PIL import Image
+
+from churro_ocr._internal.image import prepare_ocr_image
+from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG, strip_ocr_output_tag
+from churro_ocr.templates import (
+ CHURRO_3B_MODEL_ID,
+ CHURRO_3B_XML_TEMPLATE,
+ DEFAULT_OCR_TEMPLATE,
+ DOTS_OCR_1_5_MODEL_ID,
+ DOTS_OCR_1_5_OCR_TEMPLATE,
+ OCRConversation,
+ OCRPromptTemplateLike,
+)
+
+if TYPE_CHECKING:
+ pass
+
+
+OCRProvider = Literal["litellm", "openai-compatible", "azure", "mistral", "hf", "vllm"]
+ImagePreprocessor = Callable[[Image.Image], Image.Image]
+TextPostprocessor = Callable[[str], str]
+VisionInputBuilder = Callable[[OCRConversation], object]
+DEFAULT_OCR_MAX_TOKENS = 20_000
+
+
+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)
+
+
+@dataclass(slots=True, frozen=True)
+class LiteLLMTransportConfig:
+ """Shared transport config for LiteLLM-based multimodal requests.
+
+ :param api_base: Optional API base URL override.
+ :param api_key: Optional API key forwarded to LiteLLM.
+ :param api_version: Optional API version string for providers that need one.
+ :param image_detail: Optional image-detail hint supported by some providers.
+ :param completion_kwargs: Extra completion kwargs merged into LiteLLM calls.
+ :param cache_dir: Optional disk-cache directory for LiteLLM request caching.
+ """
+
+ api_base: str | None = None
+ api_key: str | None = None
+ api_version: str | None = None
+ image_detail: str | None = None
+ completion_kwargs: dict[str, object] = field(default_factory=dict)
+ cache_dir: str | Path | None = None
+
+
+@dataclass(slots=True, frozen=True)
+class OpenAICompatibleOptions:
+ """Provider options for OpenAI-compatible OCR servers.
+
+ :param model_prefix: Provider prefix prepended to the configured model name.
+ """
+
+ model_prefix: str | None = None
+
+
+@dataclass(slots=True, frozen=True)
+class HuggingFaceOptions:
+ """Provider options for local Hugging Face OCR backends.
+
+ :param trust_remote_code: Whether to allow remote model code execution.
+ :param processor_kwargs: Extra kwargs passed to ``AutoProcessor.from_pretrained``.
+ :param model_kwargs: Extra kwargs passed to model loading.
+ :param generation_kwargs: Extra generation kwargs passed at inference time.
+ :param vision_input_builder: Optional override for building multimodal inputs.
+ :param backend_variant: Optional implementation preset such as ``"dots-ocr-1.5"``.
+ """
+
+ trust_remote_code: bool | None = None
+ processor_kwargs: dict[str, object] = field(default_factory=dict)
+ model_kwargs: dict[str, object] = field(default_factory=dict)
+ generation_kwargs: dict[str, object] = field(default_factory=dict)
+ vision_input_builder: VisionInputBuilder | None = None
+ backend_variant: str | None = None
+
+
+@dataclass(slots=True, frozen=True)
+class VLLMOptions:
+ """Provider options for local vLLM OCR backends.
+
+ :param trust_remote_code: Whether to allow remote model code execution.
+ :param processor_kwargs: Extra kwargs passed to ``AutoProcessor.from_pretrained``.
+ :param llm_kwargs: Extra kwargs passed to the vLLM ``LLM`` constructor.
+ :param sampling_kwargs: Extra kwargs passed to vLLM sampling params.
+ :param limit_mm_per_prompt: Per-request multimodal limits passed to vLLM.
+ """
+
+ trust_remote_code: bool | None = None
+ processor_kwargs: dict[str, object] = field(default_factory=dict)
+ llm_kwargs: dict[str, object] = field(default_factory=dict)
+ sampling_kwargs: dict[str, object] = field(default_factory=dict)
+ limit_mm_per_prompt: dict[str, int] = field(default_factory=dict)
+
+
+@dataclass(slots=True, frozen=True)
+class AzureDocumentIntelligenceOptions:
+ """Provider options for Azure Document Intelligence OCR.
+
+ :param endpoint: Azure Document Intelligence endpoint URL.
+ :param api_key: Azure API key for the configured resource.
+ """
+
+ endpoint: str | None = None
+ api_key: str | None = None
+
+
+@dataclass(slots=True, frozen=True)
+class MistralOptions:
+ """Provider options for Mistral OCR.
+
+ :param api_key: Mistral API key used for OCR requests.
+ """
+
+ api_key: str | None = None
+
+
+OCRProviderOptions = (
+ OpenAICompatibleOptions
+ | HuggingFaceOptions
+ | VLLMOptions
+ | AzureDocumentIntelligenceOptions
+ | MistralOptions
+)
+
+
+@dataclass(slots=True, frozen=True)
+class OCRModelProfile:
+ """Model-level OCR behavior shared across provider adapters.
+
+ :param profile_name: Stable profile identifier.
+ :param template: Prompt template used to render OCR input.
+ :param image_preprocessor: Image preprocessor applied before OCR.
+ :param text_postprocessor: Text postprocessor applied after OCR.
+ :param display_name: Optional human-readable model name.
+ :param transport: Default LiteLLM transport settings for this profile.
+ :param huggingface: Default Hugging Face backend options for this profile.
+ :param vllm: Default vLLM backend options for this profile.
+ """
+
+ profile_name: str
+ template: OCRPromptTemplateLike = DEFAULT_OCR_TEMPLATE
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor
+ text_postprocessor: TextPostprocessor = default_ocr_text_postprocessor
+ display_name: str | None = None
+ transport: LiteLLMTransportConfig = field(default_factory=LiteLLMTransportConfig)
+ huggingface: HuggingFaceOptions = field(default_factory=HuggingFaceOptions)
+ vllm: VLLMOptions = field(default_factory=VLLMOptions)
+
+
+@dataclass(slots=True, frozen=True)
+class OCRBackendSpec:
+ """Declarative builder input for OCR backends.
+
+ :param provider: OCR provider identifier.
+ :param model: Provider-specific model identifier.
+ :param profile: Optional built-in or custom model profile.
+ :param transport: Optional transport settings for LiteLLM-based providers.
+ :param options: Optional provider-specific options dataclass.
+ """
+
+ provider: OCRProvider
+ model: str | None = None
+ profile: str | OCRModelProfile | None = None
+ transport: LiteLLMTransportConfig | None = None
+ options: OCRProviderOptions | None = None
+
+
+def default_ocr_profile() -> OCRModelProfile:
+ """Return the generic OCR model profile.
+
+ :returns: Baseline profile used when no more specific profile matches.
+ """
+ return OCRModelProfile(profile_name="default")
+
+
+def churro_3b_profile() -> OCRModelProfile:
+ """Return the built-in ``stanford-oval/churro-3B`` OCR profile.
+
+ :returns: Profile configured for the built-in CHURRO 3B template.
+ """
+ return OCRModelProfile(
+ profile_name=CHURRO_3B_MODEL_ID,
+ template=CHURRO_3B_XML_TEMPLATE,
+ text_postprocessor=identity_text_postprocessor,
+ display_name="churro-3B",
+ )
+
+
+def dots_ocr_1_5_profile() -> OCRModelProfile:
+ """Return the built-in ``kristaller486/dots.ocr-1.5`` OCR profile.
+
+ :returns: Profile configured for the built-in Dots OCR 1.5 template.
+ """
+ return OCRModelProfile(
+ profile_name=DOTS_OCR_1_5_MODEL_ID,
+ template=DOTS_OCR_1_5_OCR_TEMPLATE,
+ text_postprocessor=identity_text_postprocessor,
+ display_name="dots.ocr-1.5",
+ huggingface=HuggingFaceOptions(
+ trust_remote_code=True,
+ backend_variant="dots-ocr-1.5",
+ ),
+ vllm=VLLMOptions(
+ trust_remote_code=True,
+ ),
+ )
+
+
+def _profile_registry() -> dict[str, OCRModelProfile]:
+ default_profile = default_ocr_profile()
+ churro_profile = churro_3b_profile()
+ dots_profile = dots_ocr_1_5_profile()
+ return {
+ default_profile.profile_name: default_profile,
+ churro_profile.profile_name: churro_profile,
+ dots_profile.profile_name: dots_profile,
+ }
+
+
+def resolve_ocr_profile(
+ *,
+ model_id: str | None,
+ profile: str | OCRModelProfile | None = None,
+) -> OCRModelProfile:
+ """Resolve the OCR model profile for a model or explicit profile.
+
+ :param model_id: Model identifier that may map to a built-in profile.
+ :param profile: Explicit profile name or profile object to use.
+ :returns: Resolved OCR model profile.
+ :raises ValueError: If ``profile`` is a string that does not match a known profile.
+ """
+ if isinstance(profile, OCRModelProfile):
+ return profile
+
+ registry = _profile_registry()
+ if isinstance(profile, str):
+ try:
+ return registry[profile]
+ except KeyError as exc:
+ raise ValueError(f"Unknown OCR profile '{profile}'.") from exc
+
+ if model_id is not None and model_id in registry:
+ return registry[model_id]
+ return registry["default"]
+
+
+__all__ = [
+ "AzureDocumentIntelligenceOptions",
+ "DEFAULT_OCR_MAX_TOKENS",
+ "default_ocr_image_preprocessor",
+ "default_ocr_profile",
+ "default_ocr_text_postprocessor",
+ "HuggingFaceOptions",
+ "identity_text_postprocessor",
+ "ImagePreprocessor",
+ "LiteLLMTransportConfig",
+ "MistralOptions",
+ "OCRBackendSpec",
+ "OCRModelProfile",
+ "OCRProvider",
+ "OpenAICompatibleOptions",
+ "resolve_ocr_profile",
+ "TextPostprocessor",
+ "VisionInputBuilder",
+ "VLLMOptions",
+]
diff --git a/src/churro_ocr/providers/vllm.py b/src/churro_ocr/providers/vllm.py
new file mode 100644
index 0000000..1c05283
--- /dev/null
+++ b/src/churro_ocr/providers/vllm.py
@@ -0,0 +1,209 @@
+"""vLLM OCR backends."""
+
+from __future__ import annotations
+
+import asyncio
+import threading
+from dataclasses import dataclass, field
+from importlib import import_module
+from typing import Any, cast
+
+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._shared import build_ocr_result, preprocess_backend_page, render_ocr_prompt
+from churro_ocr.providers.specs import (
+ DEFAULT_OCR_MAX_TOKENS,
+ ImagePreprocessor,
+ TextPostprocessor,
+ default_ocr_image_preprocessor,
+ identity_text_postprocessor,
+)
+from churro_ocr.templates import (
+ DOTS_OCR_1_5_MODEL_ID,
+ DOTS_OCR_1_5_OCR_TEMPLATE,
+ OCRPromptTemplateLike,
+)
+
+
+def _load_vllm_processor_cls() -> Any:
+ try:
+ from transformers import AutoProcessor
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ 'vLLM OCR requires transformers. Install with `pip install "churro-ocr[vllm]"`.'
+ ) from exc
+
+ return AutoProcessor
+
+
+def _load_vllm_runtime() -> tuple[Any, Any]:
+ try:
+ vllm = import_module("vllm")
+ except ImportError as exc: # pragma: no cover - optional extra path
+ raise ConfigurationError(
+ 'vLLM OCR requires the "vllm" extra. Install with `pip install "churro-ocr[vllm]"`.'
+ ) from exc
+
+ vllm_any = cast(Any, vllm)
+ return vllm_any.LLM, vllm_any.SamplingParams
+
+
+@dataclass(slots=True)
+class VLLMVisionOCRBackend(OCRBackend):
+ """OCR backend for local multimodal models served by vLLM.
+
+ :param model_id: Model identifier served by vLLM.
+ :param template: Prompt template used to render OCR input.
+ :param model_name: Optional human-readable model name for result metadata.
+ :param trust_remote_code: Whether to allow remote model code execution.
+ :param processor_kwargs: Extra kwargs passed to processor loading.
+ :param llm_kwargs: Extra kwargs passed to the vLLM ``LLM`` constructor.
+ :param sampling_kwargs: Extra sampling kwargs passed at inference time.
+ :param limit_mm_per_prompt: Multimodal limits passed to vLLM.
+ :param image_preprocessor: Image preprocessor applied before OCR.
+ :param text_postprocessor: Text postprocessor applied after OCR.
+ :param provider_name: Provider identifier written into OCR results.
+ """
+
+ model_id: str
+ template: OCRPromptTemplateLike
+ model_name: str | None = None
+ trust_remote_code: bool = False
+ processor_kwargs: dict[str, object] = field(default_factory=dict)
+ llm_kwargs: dict[str, object] = field(default_factory=dict)
+ sampling_kwargs: dict[str, object] = field(default_factory=dict)
+ limit_mm_per_prompt: dict[str, int] = field(default_factory=lambda: {"image": 1})
+ image_preprocessor: ImagePreprocessor = default_ocr_image_preprocessor
+ text_postprocessor: TextPostprocessor = identity_text_postprocessor
+ provider_name: str = "vllm"
+ _processor: object | None = field(default=None, init=False, repr=False)
+ _llm: object | None = field(default=None, init=False, repr=False)
+ _init_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False)
+ _has_logged_prompt: bool = field(default=False, init=False, repr=False)
+ _prompt_log_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
+
+ def __post_init__(self) -> None:
+ """Apply default sampling settings after dataclass initialization."""
+ self.sampling_kwargs = {
+ "max_tokens": DEFAULT_OCR_MAX_TOKENS,
+ **self.sampling_kwargs,
+ }
+
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ """Run OCR for one page.
+
+ :param page: Page to transcribe.
+ :returns: Provider-agnostic OCR result.
+ """
+ return (await self.ocr_batch([page]))[0]
+
+ async def ocr_batch(self, pages: list[DocumentPage]) -> list[OCRResult]:
+ """Run OCR for multiple pages in one batch.
+
+ :param pages: Pages to transcribe in batch order.
+ :returns: OCR results in the same order as ``pages``.
+ """
+ return await asyncio.to_thread(self._ocr_batch_sync, pages)
+
+ def _ocr_batch_sync(self, pages: list[DocumentPage]) -> list[OCRResult]:
+ if not pages:
+ return []
+
+ processor = self._get_processor()
+ llm = self._get_llm()
+ _, sampling_params_cls = _load_vllm_runtime()
+ prompts: list[dict[str, object]] = []
+
+ for page in pages:
+ prepared_page = preprocess_backend_page(
+ page,
+ image_preprocessor=self.image_preprocessor,
+ )
+ rendered, _ = render_ocr_prompt(
+ processor,
+ self.template,
+ prepared_page,
+ add_generation_prompt=True,
+ )
+ prompt_payload: dict[str, object] = {
+ "prompt": rendered,
+ "multi_modal_data": {"image": prepared_page.image},
+ }
+ prompts.append(prompt_payload)
+ if not self._has_logged_prompt:
+ log_prompt_payload_once(
+ payload={
+ "batch_size": len(pages),
+ "prompt": prompt_payload,
+ },
+ 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),
+ )
+
+ request_outputs = llm.generate(
+ prompts,
+ sampling_params_cls(**self.sampling_kwargs),
+ use_tqdm=False,
+ )
+ results: list[OCRResult] = []
+ for request_output in request_outputs:
+ outputs = getattr(request_output, "outputs", None)
+ if not outputs:
+ raise ProviderError("vLLM OCR returned no outputs.")
+ text = getattr(outputs[0], "text", None)
+ if not isinstance(text, str):
+ raise ProviderError("vLLM OCR returned a non-text response.")
+ results.append(
+ build_ocr_result(
+ text,
+ provider_name=self.provider_name,
+ model_name=self.model_name or self.model_id,
+ text_postprocessor=self.text_postprocessor,
+ )
+ )
+ return results
+
+ def _get_processor(self) -> Any:
+ if self._processor is None:
+ with self._init_lock:
+ if self._processor is None:
+ processor_cls = _load_vllm_processor_cls()
+ self._processor = processor_cls.from_pretrained(
+ self.model_id,
+ trust_remote_code=self.trust_remote_code,
+ **self.processor_kwargs,
+ )
+ return self._processor
+
+ def _get_llm(self) -> Any:
+ if self._llm is None:
+ with self._init_lock:
+ if self._llm is None:
+ llm_cls, _ = _load_vllm_runtime()
+ self._llm = llm_cls(
+ model=self.model_id,
+ trust_remote_code=self.trust_remote_code,
+ limit_mm_per_prompt=self.limit_mm_per_prompt,
+ **self.llm_kwargs,
+ )
+ return self._llm
+
+
+@dataclass(slots=True)
+class DotsOCR15VLLMOCRBackend(VLLMVisionOCRBackend):
+ """Preset vLLM OCR backend for ``kristaller486/dots.ocr-1.5``."""
+
+ model_id: str = DOTS_OCR_1_5_MODEL_ID
+ template: OCRPromptTemplateLike = DOTS_OCR_1_5_OCR_TEMPLATE
+ model_name: str | None = "dots.ocr-1.5"
+ trust_remote_code: bool = True
+
+
+__all__ = [
+ "DotsOCR15VLLMOCRBackend",
+ "VLLMVisionOCRBackend",
+]
diff --git a/src/churro_ocr/py.typed b/src/churro_ocr/py.typed
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/src/churro_ocr/py.typed
@@ -0,0 +1 @@
+
diff --git a/src/churro_ocr/templates/__init__.py b/src/churro_ocr/templates/__init__.py
new file mode 100644
index 0000000..16f76b0
--- /dev/null
+++ b/src/churro_ocr/templates/__init__.py
@@ -0,0 +1,33 @@
+"""Template helpers for model-specific OCR input rendering."""
+
+from churro_ocr.templates.base import (
+ OCRConversation,
+ OCRPromptTemplate,
+ OCRPromptTemplateCallable,
+ OCRPromptTemplateLike,
+ build_ocr_conversation,
+)
+from churro_ocr.templates.hf import HFChatTemplate
+from churro_ocr.templates.presets import (
+ CHURRO_3B_MODEL_ID,
+ CHURRO_3B_XML_TEMPLATE,
+ DEFAULT_OCR_TEMPLATE,
+ DOTS_OCR_1_5_MODEL_ID,
+ DOTS_OCR_1_5_OCR_PROMPT,
+ DOTS_OCR_1_5_OCR_TEMPLATE,
+)
+
+__all__ = [
+ "build_ocr_conversation",
+ "CHURRO_3B_MODEL_ID",
+ "CHURRO_3B_XML_TEMPLATE",
+ "DEFAULT_OCR_TEMPLATE",
+ "DOTS_OCR_1_5_MODEL_ID",
+ "DOTS_OCR_1_5_OCR_PROMPT",
+ "DOTS_OCR_1_5_OCR_TEMPLATE",
+ "HFChatTemplate",
+ "OCRConversation",
+ "OCRPromptTemplate",
+ "OCRPromptTemplateCallable",
+ "OCRPromptTemplateLike",
+]
diff --git a/src/churro_ocr/templates/base.py b/src/churro_ocr/templates/base.py
new file mode 100644
index 0000000..d9d2b18
--- /dev/null
+++ b/src/churro_ocr/templates/base.py
@@ -0,0 +1,56 @@
+"""Provider-neutral template protocols for OCR backends."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any, Protocol, runtime_checkable
+
+from churro_ocr.page_detection import DocumentPage
+
+OCRConversation = list[dict[str, Any]]
+
+
+@runtime_checkable
+class OCRPromptTemplate(Protocol):
+ """Protocol for OCR templates that build model conversations."""
+
+ def build_conversation(self, page: DocumentPage) -> OCRConversation:
+ """Build a model conversation for one page.
+
+ :param page: Page to convert into a model-specific prompt payload.
+ :returns: Structured conversation ready for backend-specific rendering.
+ """
+ ...
+
+
+OCRPromptTemplateCallable = Callable[[DocumentPage], OCRConversation]
+OCRPromptTemplateLike = OCRPromptTemplate | OCRPromptTemplateCallable
+
+
+def build_ocr_conversation(template: OCRPromptTemplateLike, page: DocumentPage) -> OCRConversation:
+ """Build an OCR conversation from a template or template callable.
+
+ :param template: Prompt template object or callable.
+ :param page: Page to convert into a conversation.
+ :returns: Structured OCR conversation for ``page``.
+ """
+ if callable(template) and not isinstance(template, OCRPromptTemplate):
+ return template(page)
+ return template.build_conversation(page)
+
+
+# Internal aliases kept to make the refactor incremental while the
+# provider implementations migrate off the old HF-prefixed names.
+HFConversation = OCRConversation
+HFOCRTemplate = OCRPromptTemplate
+HFOCRTemplateCallable = OCRPromptTemplateCallable
+HFOCRTemplateLike = OCRPromptTemplateLike
+
+
+__all__ = [
+ "build_ocr_conversation",
+ "OCRConversation",
+ "OCRPromptTemplate",
+ "OCRPromptTemplateCallable",
+ "OCRPromptTemplateLike",
+]
diff --git a/src/churro_ocr/templates/hf.py b/src/churro_ocr/templates/hf.py
new file mode 100644
index 0000000..16a1987
--- /dev/null
+++ b/src/churro_ocr/templates/hf.py
@@ -0,0 +1,49 @@
+"""Built-in OCR templates."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from churro_ocr.page_detection import DocumentPage
+from churro_ocr.templates.base import OCRConversation
+
+
+@dataclass(slots=True, frozen=True)
+class HFChatTemplate:
+ """Template for processor/tokenizer chat-template OCR models.
+
+ :param system_message: Optional system message prepended to the conversation.
+ :param user_prompt: Optional user-side text prompt appended with the image.
+ :param include_image: Whether to include the page image in the user message.
+ """
+
+ system_message: str | None = None
+ user_prompt: str | None = None
+ include_image: bool = True
+
+ def build_conversation(self, page: DocumentPage) -> OCRConversation:
+ """Build a structured multimodal conversation for one OCR page.
+
+ :param page: Page to represent in the conversation.
+ :returns: Conversation payload suitable for chat-template OCR models.
+ """
+ conversation: OCRConversation = []
+ if self.system_message:
+ conversation.append(
+ {
+ "role": "system",
+ "content": [{"type": "text", "text": self.system_message}],
+ }
+ )
+
+ user_content: list[dict[str, object]] = []
+ if self.include_image:
+ user_content.append({"type": "image", "image": page.image.copy()})
+ if self.user_prompt:
+ user_content.append({"type": "text", "text": self.user_prompt})
+
+ conversation.append({"role": "user", "content": user_content})
+ return conversation
+
+
+__all__ = ["HFChatTemplate"]
diff --git a/src/churro_ocr/templates/presets.py b/src/churro_ocr/templates/presets.py
new file mode 100644
index 0000000..9909db8
--- /dev/null
+++ b/src/churro_ocr/templates/presets.py
@@ -0,0 +1,33 @@
+"""Built-in OCR template presets."""
+
+from __future__ import annotations
+
+from churro_ocr.prompts import DEFAULT_OCR_SYSTEM_PROMPT, DEFAULT_OCR_USER_PROMPT
+from churro_ocr.templates.hf import HFChatTemplate
+
+CHURRO_3B_MODEL_ID = "stanford-oval/churro-3B"
+DOTS_OCR_1_5_MODEL_ID = "kristaller486/dots.ocr-1.5"
+DEFAULT_OCR_TEMPLATE = HFChatTemplate(
+ system_message=DEFAULT_OCR_SYSTEM_PROMPT,
+ user_prompt=DEFAULT_OCR_USER_PROMPT,
+)
+
+CHURRO_3B_XML_TEMPLATE = HFChatTemplate(
+ system_message="Transcribe the entirety of this historical document to XML format.",
+ user_prompt=None,
+)
+DOTS_OCR_1_5_OCR_PROMPT = "Extract the text content from this image."
+DOTS_OCR_1_5_OCR_TEMPLATE = HFChatTemplate(
+ system_message=None,
+ user_prompt=DOTS_OCR_1_5_OCR_PROMPT,
+)
+
+
+__all__ = [
+ "CHURRO_3B_MODEL_ID",
+ "CHURRO_3B_XML_TEMPLATE",
+ "DEFAULT_OCR_TEMPLATE",
+ "DOTS_OCR_1_5_MODEL_ID",
+ "DOTS_OCR_1_5_OCR_PROMPT",
+ "DOTS_OCR_1_5_OCR_TEMPLATE",
+]
diff --git a/systems/__init__.py b/systems/__init__.py
deleted file mode 100644
index 448a6ed..0000000
--- a/systems/__init__.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""OCR systems package."""
-
-from importlib import import_module
-from typing import TYPE_CHECKING
-
-
-__all__ = [
- "BaseOCR",
- "ZeroShotLLMOCR",
- "AzureOCR",
- "MistralOCR",
- "FineTunedOCR",
- "OCRFactory",
- "LLMImprover",
-]
-
-_LAZY_MAP = {
- "BaseOCR": "churro.systems.base_ocr:BaseOCR",
- "ZeroShotLLMOCR": "churro.systems.llm_ocr:ZeroShotLLMOCR",
- "AzureOCR": "churro.systems.azure_ocr:AzureOCR",
- "MistralOCR": "churro.systems.mistral_ocr:MistralOCR",
- "FineTunedOCR": "churro.systems.finetuned_ocr:FineTunedOCR",
- "OCRFactory": "churro.systems.ocr_factory:OCRFactory",
- "LLMImprover": "churro.systems.llm_improver:LLMImprover",
-}
-
-_CACHE: dict[str, object] = {}
-
-if TYPE_CHECKING:
- from .azure_ocr import AzureOCR
- from .base_ocr import BaseOCR
- from .finetuned_ocr import FineTunedOCR
- from .llm_improver import LLMImprover
- from .llm_ocr import ZeroShotLLMOCR
- from .mistral_ocr import MistralOCR
- from .ocr_factory import OCRFactory
-
-
-def __getattr__(name: str) -> object:
- if name not in _LAZY_MAP:
- raise AttributeError(f"module 'churro.systems' has no attribute '{name}'")
- if name in _CACHE:
- return _CACHE[name]
- module_path, attr = _LAZY_MAP[name].split(":", 1)
- module = import_module(module_path)
- resolved = getattr(module, attr)
- _CACHE[name] = resolved
- return resolved
-
-
-def __dir__() -> list[str]:
- return sorted(__all__)
diff --git a/systems/azure_ocr.py b/systems/azure_ocr.py
deleted file mode 100644
index dc18c50..0000000
--- a/systems/azure_ocr.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Azure OCR implementations."""
-
-from typing import override
-
-from PIL import Image
-
-from .base_ocr import BaseOCR
-from .detect_layout import (
- run_azure_document_analysis_on_image,
-)
-
-
-class AzureOCR(BaseOCR):
- """Azure Document Intelligence OCR."""
-
- def __init__(self, **kwargs: object) -> None:
- super().__init__(**kwargs)
- self._initialized = False
-
- @override
- async def process_image(self, image: Image.Image) -> str:
- """Process a single image using Azure OCR."""
- azure_output = await run_azure_document_analysis_on_image(
- image, skip_paragraphs=False, output_ocr_text=True
- )
- assert isinstance(azure_output, str), (
- "Azure OCR output must be a string when output_ocr_text=True"
- )
- return azure_output
-
- def get_system_name(self) -> str:
- """Return human-readable system name."""
- return "Azure Document Intelligence OCR"
diff --git a/systems/base_ocr.py b/systems/base_ocr.py
deleted file mode 100644
index 21e8c3e..0000000
--- a/systems/base_ocr.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""Base OCR class and interfaces for all OCR systems."""
-
-from abc import ABC, abstractmethod
-from typing import cast
-
-from PIL import Image
-
-from churro.utils.concurrency import run_async_in_parallel
-from churro.utils.image.io import load_image_async
-from churro.utils.log_utils import logger
-
-
-class BaseOCR(ABC):
- """Base class for all OCR systems."""
-
- def __init__(self, **kwargs: object) -> None:
- """Initialize the OCR system with configuration parameters."""
- self.config: dict[str, object] = kwargs
-
- @abstractmethod
- async def process_image(self, image: Image.Image) -> str:
- """Process a single image and return the OCR result."""
- pass
-
- @abstractmethod
- def get_system_name(self) -> str:
- """Return the name of the OCR system."""
- pass
-
- async def process_images(self, images: list[Image.Image], *, max_concurrency: int) -> list[str]:
- """Process a list of images and return the OCR results."""
- raw_results = await run_async_in_parallel(
- self.process_image,
- images,
- max_concurrency=max_concurrency,
- desc=self.get_system_name(),
- )
- processed: list[str] = []
- for index, result in enumerate(raw_results):
- if result is None:
- message = (
- f"{self.get_system_name()} failed to process in-memory image at index {index}."
- )
- logger.error(message)
- result = ""
- processed.append(cast(str, result))
- return processed
-
- async def process_image_from_file(self, image_path: str) -> str:
- """Process a single image from a file path and return the OCR result."""
- image = await load_image_async(image_path)
- return await self.process_image(image)
-
- async def process_images_from_files(
- self, image_paths: list[str], max_concurrency: int
- ) -> list[str]:
- """Process a list of images from file paths and return the OCR results."""
- raw_results = await run_async_in_parallel(
- self.process_image_from_file,
- image_paths,
- max_concurrency=max_concurrency,
- desc=self.get_system_name(),
- )
- processed: list[str] = []
- for path, result in zip(image_paths, raw_results, strict=False):
- if result is None:
- message = f"{self.get_system_name()} failed to process file {path}."
- logger.error(message)
- result = ""
- processed.append(cast(str, result))
- return processed
diff --git a/systems/detect_layout.py b/systems/detect_layout.py
deleted file mode 100644
index f15bd57..0000000
--- a/systems/detect_layout.py
+++ /dev/null
@@ -1,541 +0,0 @@
-"""Azure Document Intelligence-powered layout detection utilities.
-
-This module provides helpers to detect page layout using Azure Document
-Intelligence (prebuilt-layout) and apply basic geometric transformations
-to align and then restore coordinates on the original image. It exposes a
-single high-level coroutine, `detect_layout`, that returns:
-
-- the processed `Page` (post-crop/rotation),
-- the transformed `Image` (post-crop/rotation), and
-- a second `Page` whose coordinates refer back to the original image
- (all transformations reversed).
-
-"""
-
-from __future__ import annotations
-
-import asyncio
-import hashlib
-from io import BytesIO
-import math
-from pathlib import Path
-import struct
-from typing import Any, Literal, cast
-
-from azure.ai.documentintelligence.aio import DocumentIntelligenceClient
-from azure.ai.documentintelligence.models import AnalyzeResult, DocumentAnalysisFeature
-from azure.core.credentials import AzureKeyCredential
-from azure.core.exceptions import HttpResponseError
-from diskcache import Cache
-from google.api_core.client_options import ClientOptions
-from google.api_core.exceptions import GoogleAPICallError
-from google.cloud import documentai
-from PIL.Image import Image
-from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
-
-from churro.config.settings import get_settings
-from churro.page.page import Page
-from churro.page.page_object import PageObject
-from churro.page.visualization import crop_image_to_objects
-from churro.utils.image.transform import adjust_image, rotate_image_and_page
-from churro.utils.log_utils import logger
-
-
-# -----------------------------
-# Constants and type aliases
-# -----------------------------
-_SETTINGS = get_settings()
-
-# Azure Document Intelligence model and request settings
-AZURE_DI_ENDPOINT: str | None = _SETTINGS.azure_document_intelligence.endpoint
-AZURE_DI_API_KEY: str | None = _SETTINGS.azure_document_intelligence.api_key
-AZURE_LAYOUT_MODEL_ID: str = "prebuilt-layout"
-AZURE_CONTENT_TYPE: str = "application/octet-stream"
-
-# Pricing ($10 layout + $6 high-res) per 1000 pages
-AZURE_DI_COST_PER_PAGE_USD: float = 16 / 1000
-
-# Transformation tracking: either ("rotate", angle) or ("shift", dx, dy)
-Transformation = tuple[Literal["rotate"], float] | tuple[Literal["shift"], float, float]
-
-# Google Document AI OCR settings
-GOOGLE_PROJECT_ID: str | None = _SETTINGS.vertex_ai.project_id
-GOOGLE_LOCATION: str = _SETTINGS.vertex_ai.document_ai_location
-GOOGLE_OCR_PROCESSOR_ID: str | None = _SETTINGS.vertex_ai.ocr_processor_id
-GOOGLE_OCR_PROCESSOR_VERSION: str | None = _SETTINGS.vertex_ai.ocr_processor_version
-
-GOOGLE_DOCUMENT_AI_COST_PER_PAGE_USD: float = (
- 1.5 / 1000
-) # https://cloud.google.com/document-ai/pricing
-
-
-# -----------------------------
-# Module state
-# -----------------------------
-
-azure_document_intelligence_client: DocumentIntelligenceClient | None = None
-total_azure_cost: float = 0.0
-
-google_document_ai_client: documentai.DocumentProcessorServiceClient | None = None
-google_document_ai_endpoint: str | None = None
-total_google_document_ai_cost: float = 0.0
-
-AZURE_DI_CACHE_PATH = (
- Path(__file__).resolve().parents[1] / ".diskcache" / "azure_document_intelligence"
-)
-azure_di_cache = Cache(str(AZURE_DI_CACHE_PATH))
-
-
-def log_total_azure_cost() -> None:
- """Log the running total Azure Document Intelligence cost for this process."""
- global total_azure_cost
- logger.info(f"Total Azure Document Intelligence cost: ${total_azure_cost:.2f}")
-
-
-def get_total_azure_cost() -> float:
- """Return the accumulated Azure API cost."""
- global total_azure_cost
- return total_azure_cost
-
-
-def _build_azure_cache_key(
- image_bytes: bytes,
- skip_paragraphs: bool,
- output_ocr_text: bool,
-) -> str:
- digest = hashlib.sha256(image_bytes).hexdigest()
- return f"azure_di:{digest}:{int(skip_paragraphs)}:{int(output_ocr_text)}"
-
-
-def log_total_google_document_ai_cost() -> None:
- """Log the running total Google Document AI cost for this process."""
- global total_google_document_ai_cost
- logger.info(f"Total Google Document AI cost: ${total_google_document_ai_cost:.2f}")
-
-
-def get_total_google_document_ai_cost() -> float:
- """Return the accumulated Google Document AI cost."""
- global total_google_document_ai_cost
- return total_google_document_ai_cost
-
-
-def _determine_google_endpoint(location: str) -> str:
- if location and location.lower() != "global":
- return f"{location}-documentai.googleapis.com"
- return "documentai.googleapis.com"
-
-
-def _ensure_google_document_ai_client(
- location: str,
-) -> documentai.DocumentProcessorServiceClient:
- """Initialize or return a cached Google Document AI client for the given location."""
- global google_document_ai_client, google_document_ai_endpoint
- endpoint = _determine_google_endpoint(location)
- if google_document_ai_client is not None and google_document_ai_endpoint == endpoint:
- return google_document_ai_client
-
- client_options = ClientOptions(api_endpoint=endpoint)
- google_document_ai_client = documentai.DocumentProcessorServiceClient(
- client_options=client_options
- )
- google_document_ai_endpoint = endpoint
- return google_document_ai_client
-
-
-def _resolve_google_processor_name(
- client: documentai.DocumentProcessorServiceClient,
-) -> str:
- if GOOGLE_PROJECT_ID is None:
- raise RuntimeError(
- "Google Document AI project is not configured. Set VERTEX_AI_PROJECT_ID or GOOGLE_CLOUD_PROJECT."
- )
- if GOOGLE_OCR_PROCESSOR_ID is None:
- raise RuntimeError(
- "Google Document AI processor is not configured. Set VERTEX_AI_OCR_PROCESSOR_ID."
- )
-
- processor_name = client.processor_path(
- GOOGLE_PROJECT_ID, GOOGLE_LOCATION, GOOGLE_OCR_PROCESSOR_ID
- )
- if GOOGLE_OCR_PROCESSOR_VERSION:
- processor_name = client.processor_version_path(
- GOOGLE_PROJECT_ID,
- GOOGLE_LOCATION,
- GOOGLE_OCR_PROCESSOR_ID,
- GOOGLE_OCR_PROCESSOR_VERSION,
- )
- return processor_name
-
-
-def _layout_to_coordinates(
- layout: documentai.Document.Page.Layout | None, width: float, height: float
-) -> list[float] | None:
- if layout is None or layout.bounding_poly is None:
- return None
-
- coords: list[float] = []
- vertices = layout.bounding_poly.vertices or []
- if vertices:
- for vertex in vertices:
- if vertex.x is None or vertex.y is None:
- continue
- coords.extend([float(vertex.x), float(vertex.y)])
- else:
- normalized_vertices = layout.bounding_poly.normalized_vertices or []
- for vertex in normalized_vertices:
- if vertex.x is None or vertex.y is None:
- continue
- coords.extend([float(vertex.x) * width, float(vertex.y) * height])
-
- if len(coords) < 6 or len(coords) % 2 != 0:
- logger.debug(f"Skipping polygon creation due to invalid coordinates: {coords}")
- return None
- return coords
-
-
-def _collect_text_spans(
- layout: documentai.Document.Page.Layout | None,
-) -> list[tuple[int, int]]:
- if layout is None or layout.text_anchor is None:
- return []
- spans: list[tuple[int, int]] = []
- for segment in layout.text_anchor.text_segments:
- start_index = int(segment.start_index) if segment.start_index is not None else 0
- end_index = int(segment.end_index) if segment.end_index is not None else start_index
- spans.append((start_index, end_index))
- return spans
-
-
-def _is_span_within(span: tuple[int, int], containers: list[tuple[int, int]]) -> bool:
- start, end = span
- for container_start, container_end in containers:
- if start >= container_start and end <= container_end:
- return True
- return False
-
-
-def _build_page_from_google_document(
- document: documentai.Document,
- skip_paragraphs: bool,
- image_width: int,
- image_height: int,
-) -> Page:
- if not document.pages:
- raise RuntimeError("Google Document AI response did not contain any pages.")
-
- page_proto = document.pages[0]
- width = float(page_proto.dimension.width or image_width)
- height = float(page_proto.dimension.height or image_height)
-
- page_objects: list[PageObject] = []
- object_id = 1
- paragraph_spans: list[tuple[int, int]] = []
-
- if not skip_paragraphs:
- for paragraph in getattr(page_proto, "paragraphs", []):
- coords = _layout_to_coordinates(paragraph.layout, width, height)
- if coords is None:
- continue
- paragraph_spans.extend(_collect_text_spans(paragraph.layout))
- page_objects.append(
- PageObject(
- object_id=str(object_id),
- coordinates=coords,
- )
- )
- object_id += 1
-
- lines_added = 0
- for line in getattr(page_proto, "lines", []):
- spans = _collect_text_spans(line.layout)
- if spans and all(_is_span_within(span, paragraph_spans) for span in spans):
- continue
- coords = _layout_to_coordinates(line.layout, width, height)
- if coords is None:
- continue
- lines_added += 1
- page_objects.append(
- PageObject(
- object_id=str(object_id),
- coordinates=coords,
- )
- )
- object_id += 1
-
- if lines_added > 0:
- logger.info(f"Added {lines_added} Google OCR lines that were not part of any paragraph")
-
- page = Page(
- page_objects=page_objects,
- )
-
- return page
-
-
-def _google_skew_from_transforms(page_proto: documentai.Document.Page) -> float | None:
- transforms = getattr(page_proto, "transforms", [])
- if not transforms:
- return None
- matrix = transforms[0]
- if matrix.rows != 2 or matrix.cols != 3:
- logger.debug(f"Unexpected transform matrix dimensions: {matrix.rows}x{matrix.cols}")
- return None
- try:
- a, b, _tx, c, d, _ty = struct.unpack("<6d", matrix.data)
- except struct.error as exc:
- logger.debug(f"Failed to unpack Google transform matrix: {exc}")
- return None
- try:
- rotation_radians = math.atan2(b, a)
- except (TypeError, ValueError) as exc:
- logger.debug(f"Invalid rotation components in transform matrix: {exc}")
- return None
- return -math.degrees(rotation_radians)
-
-
-def _google_orientation_to_angle(page_proto: documentai.Document.Page) -> float:
- skew_angle = _google_skew_from_transforms(page_proto)
- if skew_angle is not None:
- return skew_angle
-
- if page_proto.layout and page_proto.layout.orientation is not None:
- orientation_map = {
- documentai.Document.Page.Layout.Orientation.PAGE_UP: 0.0,
- documentai.Document.Page.Layout.Orientation.PAGE_RIGHT: -90.0,
- documentai.Document.Page.Layout.Orientation.PAGE_DOWN: 180.0,
- documentai.Document.Page.Layout.Orientation.PAGE_LEFT: 90.0,
- }
- try:
- return orientation_map.get(page_proto.layout.orientation, 0.0)
- except AttributeError:
- orientation_name = str(page_proto.layout.orientation)
- fallback_map = {
- "PAGE_UP": 0.0,
- "PAGE_RIGHT": -90.0,
- "PAGE_DOWN": 180.0,
- "PAGE_LEFT": 90.0,
- }
- return fallback_map.get(orientation_name.upper(), 0.0)
-
- return 0.0
-
-
-@retry(
- stop=stop_after_attempt(3),
- wait=wait_fixed(3),
- retry=retry_if_exception_type(HttpResponseError),
-)
-async def run_azure_document_analysis_on_image(
- image: Image, skip_paragraphs: bool, output_ocr_text: bool = False
-) -> tuple[Page, float] | str:
- """Analyze an image with Azure Document Intelligence.
-
- Args:
- image: PIL image to analyze. The image is serialized to JPEG in memory.
- skip_paragraphs: Passed to Page conversion logic to optionally skip paragraphs.
- output_ocr_text: If True, returns the raw OCR text content instead of a Page.
-
- Returns:
- Either a tuple of (Page, angle_degrees) where angle is the page rotation angle
- reported by Azure, or a string containing the OCR text when output_ocr_text is True.
- """
- logger.info("Running Azure Document Analysis on image")
- _ensure_azure_di_initialized()
-
- # Preprocess to improve OCR/layout analysis
- image = adjust_image(image, thresholding=True)
-
- # Save the processed image to an in-memory stream
- image_stream = BytesIO()
- image.save(image_stream, format="jpeg")
- image_bytes = image_stream.getvalue()
-
- cache_key = _build_azure_cache_key(image_bytes, skip_paragraphs, output_ocr_text)
- cached_payload_raw = await asyncio.to_thread(azure_di_cache.get, cache_key)
- if cached_payload_raw is not None:
- cached_payload = cast(dict[str, Any], cached_payload_raw)
- logger.info("Azure Document Intelligence cache hit; returning cached result")
- if cached_payload["kind"] == "text":
- return cached_payload["content"]
- cached_page = Page.model_validate(cached_payload["page"])
- cached_angle = float(cached_payload.get("angle", 0.0))
- return cached_page, cached_angle
-
- image_stream = BytesIO(image_bytes)
-
- assert isinstance(azure_document_intelligence_client, DocumentIntelligenceClient), (
- "Azure Document Intelligence client is not initialized"
- )
- poller = await azure_document_intelligence_client.begin_analyze_document(
- model_id=AZURE_LAYOUT_MODEL_ID,
- body=image_stream,
- content_type=AZURE_CONTENT_TYPE,
- features=[
- DocumentAnalysisFeature.OCR_HIGH_RESOLUTION,
- ],
- )
- analysis_result: AnalyzeResult = await poller.result()
- global total_azure_cost
- total_azure_cost += AZURE_DI_COST_PER_PAGE_USD
-
- if output_ocr_text:
- cache_value: dict[str, Any] = {"kind": "text", "content": analysis_result.content}
- await asyncio.to_thread(azure_di_cache.set, cache_key, cache_value)
- return analysis_result.content
-
- page = Page.from_azure_analysis_result(analysis_result, skip_paragraphs=skip_paragraphs)
- # Robustly handle missing/None angles
- try:
- angle_value = analysis_result.pages[0].angle # type: ignore[assignment]
- except (AttributeError, IndexError):
- angle_value = 0.0
- angle: float = float(angle_value or 0.0)
-
- cache_value: dict[str, Any] = {
- "kind": "page",
- "page": page.model_dump(mode="python"),
- "angle": angle,
- }
- await asyncio.to_thread(azure_di_cache.set, cache_key, cache_value)
-
- return page, angle
-
-
-async def shutdown_layout_clients() -> None:
- """Close any cached async clients used for layout detection."""
- global azure_document_intelligence_client, google_document_ai_client
-
- if azure_document_intelligence_client is not None:
- try:
- await azure_document_intelligence_client.close()
- finally:
- azure_document_intelligence_client = None
-
- if google_document_ai_client is not None:
- close_fn = getattr(google_document_ai_client, "close", None)
- transport = getattr(google_document_ai_client, "transport", None)
- try:
- if callable(close_fn):
- close_fn()
- elif transport is not None:
- transport_close = getattr(transport, "close", None)
- if callable(transport_close):
- transport_close()
- finally:
- google_document_ai_client = None
-
-
-@retry(
- stop=stop_after_attempt(3),
- wait=wait_fixed(3),
- retry=retry_if_exception_type(GoogleAPICallError),
-)
-async def run_google_document_analysis_on_image(
- image: Image, skip_paragraphs: bool, output_ocr_text: bool = False
-) -> tuple[Page, float] | str:
- """Analyze an image with Google Document AI OCR processor."""
- logger.info("Running Google Document AI OCR on image")
-
- client = _ensure_google_document_ai_client(GOOGLE_LOCATION)
- processor_name = _resolve_google_processor_name(client)
-
- image_stream = BytesIO()
- image.save(image_stream, format="jpeg")
- document_bytes = image_stream.getvalue()
-
- request = documentai.ProcessRequest(
- name=processor_name,
- raw_document=documentai.RawDocument(content=document_bytes, mime_type="image/jpeg"),
- process_options=documentai.ProcessOptions(
- ocr_config=documentai.OcrConfig(
- enable_image_quality_scores=True,
- )
- ),
- )
-
- response = await asyncio.to_thread(client.process_document, request=request)
-
- global total_google_document_ai_cost
- total_google_document_ai_cost += GOOGLE_DOCUMENT_AI_COST_PER_PAGE_USD
-
- document = response.document
- if output_ocr_text:
- return document.text or ""
-
- page = _build_page_from_google_document(
- document,
- skip_paragraphs=skip_paragraphs,
- image_width=image.width,
- image_height=image.height,
- )
- page_proto = document.pages[0]
- angle = _google_orientation_to_angle(page_proto)
-
- return page, angle
-
-
-async def tidy_image_via_layout_detection(
- image: Image,
- margin: int = 30,
-) -> Image:
- """Detect layout and return processed and original-coordinate pages.
-
- This performs light preprocessing, calls Azure DI layout analysis, applies
- rotation and content-cropping, and then returns a deep-copied `Page` with
- all transformations reversed so the coordinates map to the original image.
-
- Args:
- image: Input page image.
- margin: Margin in pixels to add around the detected content when cropping.
-
- Returns:
- processed_image.
- """
- result = await run_azure_document_analysis_on_image(image, skip_paragraphs=False)
- # We always call with output_ocr_text=False, so result must be (Page, float)
- if isinstance(result, str): # defensive runtime check
- raise RuntimeError("Unexpected OCR text output when a Page was expected.")
- page, page_angle = result
-
- page.remove_subsumed_page_objects()
- page.remove_small_page_objects_in_margins()
-
- # Apply rotation if needed
- image, page = rotate_image_and_page(image, page_angle, page)
-
- # Crop to content and shift page objects accordingly
- image = crop_image_to_objects(image, page.page_objects, margin=margin)
-
- return image
-
-
-def _ensure_azure_di_initialized() -> DocumentIntelligenceClient:
- """Initialize or return a cached Azure DI client.
-
- Reads the API key from the AZURE_DOC_KEY environment variable and the endpoint
- from AZURE_DI_ENDPOINT (optional). A singleton client is cached in the module
- state for reuse.
-
- Returns:
- A configured `DocumentIntelligenceClient` instance.
-
- Raises:
- ValueError: If the AZURE_DOC_KEY environment variable is not set.
- """
- global azure_document_intelligence_client
- if azure_document_intelligence_client is not None:
- return azure_document_intelligence_client
-
- api_key = AZURE_DI_API_KEY
- if not api_key:
- raise ValueError("No Azure Document Key found (AZURE_DOC_KEY not set)")
-
- if AZURE_DI_ENDPOINT is None:
- raise RuntimeError(
- "AZURE_DI_ENDPOINT environment variable must be set before using Azure layout detection."
- )
- azure_document_intelligence_client = DocumentIntelligenceClient(
- endpoint=AZURE_DI_ENDPOINT,
- credential=AzureKeyCredential(api_key),
- )
- return azure_document_intelligence_client
diff --git a/systems/finetuned_ocr.py b/systems/finetuned_ocr.py
deleted file mode 100644
index e4fa0ba..0000000
--- a/systems/finetuned_ocr.py
+++ /dev/null
@@ -1,57 +0,0 @@
-"""Finetuned OCR implementation using a vLLM OpenAI-compatible server."""
-
-from __future__ import annotations
-
-from typing import override
-
-from PIL import Image
-
-from churro.evaluation.xml_utils import extract_actual_text_from_xml
-from churro.utils.llm.core import run_llm_async
-from churro.utils.llm.models import COMPLETION_TOKENS_FOR_STANDARD_MODELS
-from churro.utils.log_utils import logger
-
-from .base_ocr import BaseOCR
-
-
-SYSTEM_MESSAGE = """Transcribe the entiretly of this historical documents to XML format."""
-
-
-class FineTunedOCR(BaseOCR):
- """Finetuned model OCR using a locally hosted vLLM HTTP server."""
-
- def __init__(
- self,
- engine: str,
- max_new_tokens: int = COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- strip_xml: bool = False,
- **_: object,
- ) -> None:
- self.engine = engine
- self.max_new_tokens = max_new_tokens
- self._system_message: str = SYSTEM_MESSAGE
- self._strip_xml = strip_xml
-
- @override
- async def process_image(self, image: Image.Image) -> str:
- """Process a single image using the finetuned OCR model."""
- llm_output = await run_llm_async(
- model=self.engine,
- system_prompt_text=self._system_message,
- user_message_text=None,
- user_message_image=image,
- )
- if not isinstance(llm_output, str):
- llm_output = ""
- if self._strip_xml and llm_output:
- try:
- llm_output = extract_actual_text_from_xml(llm_output)
- except Exception:
- logger.exception(
- "Failed to extract text from finetuned XML output; returning raw XML."
- )
- return llm_output
-
- def get_system_name(self) -> str:
- """Return human-readable system name."""
- return "Fine-tuned OCR Model"
diff --git a/systems/llm_improver.py b/systems/llm_improver.py
deleted file mode 100644
index fefa7c0..0000000
--- a/systems/llm_improver.py
+++ /dev/null
@@ -1,142 +0,0 @@
-"""LLM-based OCR improvement implementation."""
-
-from functools import partial
-import textwrap
-from typing import Literal, cast
-
-from churro.utils.concurrency import run_async_in_parallel
-from churro.utils.image.io import load_image_async
-from churro.utils.image.transform import resize_image_to_fit
-from churro.utils.llm import LLMInferenceError, extract_tag_from_llm_output, run_llm_async
-from churro.utils.log_utils import logger
-
-
-class LLMImprover:
- """LLM-based OCR improvement using vision models."""
-
- def __init__(
- self,
- engine: str,
- resize: int | None = None,
- image_fidelity: Literal["high", "low"] = "high",
- backup_engine: str | None = None,
- **kwargs: object,
- ) -> None:
- super().__init__(**kwargs)
- self.engine = engine
- self.resize = resize
- self.image_fidelity = image_fidelity
-
- if not backup_engine:
- self.backup_engine = engine
- else:
- self.backup_engine = backup_engine
-
- self.system_prompt = (
- "You are a meticulous historical document editor. Improve OCR text by fixing "
- "recognition errors and formatting while preserving every original word and "
- "punctuation mark. Return the final document inside and "
- " tags."
- )
-
- self.instruction_template = """You receive a document image and its OCR text.
-
- Follow these steps:
-
- 1. Compare the image and OCR text, correcting transcription and formatting mistakes.
- 2. Merge split words, handle punctuation precisely, and preserve any non-English text.
- 3. Apply Markdown only when it is clearly supported by the image (headings, bold, italics).
- 4. Clean Markdown layout without changing words or punctuation; adjust only structure, whitespace, and line breaks. Rejoin hyphenated line breaks such as 'co-\\noperate' -> 'cooperate'.
- 5. Return the complete improved document exactly once between the tags, with no commentary, metadata, or code fences.
-
- Original OCR text:
- {ocr_text}
-
- Output format:
-
-
- [Improved document here]
- """
-
- async def process_batch_inputs(
- self, image_paths: list[str], texts: list[str], max_concurrency: int
- ) -> list[str]:
- """Process a batch of image-text pairs using LLM OCR improvement."""
- raw_results = await run_async_in_parallel(
- partial(self._process_single_input),
- image_paths,
- texts,
- max_concurrency=max_concurrency,
- desc="LLM OCR Improvement",
- )
- improved_texts: list[str] = []
- for image_path, original_text, result in zip(image_paths, texts, raw_results, strict=False):
- if result is None:
- logger.warning(f"LLM improvement failed for {image_path}; returning original text.")
- improved_texts.append(original_text)
- else:
- improved_texts.append(result)
- return improved_texts
-
- async def _process_single_input(self, image_path: str, ocr_text: str) -> str:
- """Process a single image-text pair using LLM OCR improvement."""
- image = await load_image_async(image_path)
-
- if self.resize:
- image = resize_image_to_fit(image, self.resize, self.resize)
-
- instruction = textwrap.dedent(self.instruction_template.format(ocr_text=ocr_text))
-
- kwargs_for_llm = {
- "model": self.engine,
- "system_prompt_text": self.system_prompt,
- "user_message_text": instruction,
- "user_message_image": image,
- "image_detail": self.image_fidelity,
- "timeout": 60 * 5,
- }
-
- try:
- llm_output = await run_llm_async(**kwargs_for_llm)
- except LLMInferenceError as exc:
- logger.warning(
- f"LLM improvement primary engine '{self.engine}' failed for {image_path}: {exc}"
- )
- if self.backup_engine and self.backup_engine != self.engine:
- kwargs_for_llm["model"] = self.backup_engine
- try:
- llm_output = await run_llm_async(**kwargs_for_llm)
- except LLMInferenceError as backup_exc:
- logger.error(
- f"LLM improvement backup engine '{self.backup_engine}' failed for {image_path}: {backup_exc}"
- )
- raise
- else:
- raise
-
- if "" not in llm_output:
- logger.warning(
- f"LLM output does not contain tag. Output: {llm_output}."
- )
- improved_output = ""
- else:
- improved_output = cast(
- str, extract_tag_from_llm_output(llm_output, tags="improved_text")
- )
-
- if not improved_output:
- if self.backup_engine and self.backup_engine != self.engine:
- logger.warning(f"LLM output is empty. Retrying with {self.backup_engine}.")
- kwargs_for_llm["model"] = self.backup_engine
- try:
- llm_output = await run_llm_async(**kwargs_for_llm)
- except LLMInferenceError as backup_exc:
- logger.error(
- f"LLM improvement backup engine '{self.backup_engine}' failed for {image_path}: {backup_exc}"
- )
- raise
- improved_output = cast(
- str, extract_tag_from_llm_output(llm_output, tags="improved_text")
- )
-
- return improved_output.strip() if improved_output else ocr_text
diff --git a/systems/llm_ocr.py b/systems/llm_ocr.py
deleted file mode 100644
index fc55ccc..0000000
--- a/systems/llm_ocr.py
+++ /dev/null
@@ -1,150 +0,0 @@
-"""LLM-based OCR implementation."""
-
-from typing import Literal, override
-
-from PIL import Image
-
-from churro.utils.image.transform import resize_image_to_fit
-from churro.utils.llm import LLMInferenceError, extract_tag_from_llm_output, run_llm_async
-from churro.utils.log_utils import logger
-
-from .base_ocr import BaseOCR
-
-
-class ZeroShotLLMOCR(BaseOCR):
- """LLM-based OCR using vision models."""
-
- OUTPUT_TAG = "answer"
-
- def __init__(
- self,
- engine: str,
- reasoning_effort: Literal["low", "medium", "high"] | None = None,
- resize: int | None = None,
- output_markdown: bool = False,
- backup_engine: str | None = None,
- **kwargs: object,
- ) -> None:
- super().__init__(**kwargs)
- self.engine = engine
- self.resize = resize
- self.reasoning_effort = reasoning_effort
- self.backup_engine = backup_engine
-
- if output_markdown:
- self.system_prompt = (
- "You are an expert in transcription of historical documents "
- "from various languages. Your task is to extract the full text from a "
- f"given page in Markdown format. Only output the markdown text between <{self.OUTPUT_TAG}> and {self.OUTPUT_TAG}> tags."
- )
- else:
- self.system_prompt = (
- "You are an expert in diplomatic transcription of historical documents "
- "from various languages. Your task is to extract the full text from a "
- f"given page. Only output the transcribed text between <{self.OUTPUT_TAG}> and {self.OUTPUT_TAG}> tags."
- )
-
- self.instruction = f"""Follow these instructions:
-
- 1. You will be provided with a scanned document page.
-
- 2. Perform transcription on the entirety of the page, converting all visible text into the following format. Include handwritten and print text, if any. Include tables, captions, headers, main text and all other visible text.
-
- 3. If you encounter any non-text elements, simply skip them without attempting to describe them.
-
- 4. Do not modernize or standardize the text. For example, if the transcription is using "ſ" instead of "s" or "а" instead of "a", keep it that way.
-
- 5. When you come across text in languages other than English, transcribe it as accurately as possible without translation.
-
- 6. Output the OCR result in the following format:
-
- <{self.OUTPUT_TAG}>
- extracted text here
- {self.OUTPUT_TAG}>
-
- Remember, your goal is to accurately transcribe the text from the scanned page as much as possible. Process the entire page, even if it contains a large amount of text, and provide clear, well-formatted output. Pay attention to the appropriate reading order and layout of the text."""
-
- @override
- async def process_image(self, image: Image.Image) -> str:
- """Process a single image using LLM OCR."""
- if self.resize:
- image = resize_image_to_fit(image, self.resize, self.resize)
-
- kwargs_for_llm = {
- "model": self.engine,
- "system_prompt_text": self.system_prompt,
- "user_message_text": self.instruction,
- "user_message_image": image,
- "image_detail": "high",
- "timeout": 60 * 10, # 10 minutes
- }
- if self.reasoning_effort:
- kwargs_for_llm["reasoning_effort"] = self.reasoning_effort
-
- engines_to_try: list[str] = [self.engine]
- if self.backup_engine and self.backup_engine != self.engine:
- engines_to_try.append(self.backup_engine)
-
- last_error: Exception | None = None
- extracted_output: str | None = None
- for current_engine in engines_to_try:
- kwargs_for_llm["model"] = current_engine
- try:
- llm_output = await run_llm_async(**kwargs_for_llm)
- except LLMInferenceError as exc:
- last_error = exc
- logger.warning(
- f"LLM engine '{current_engine}' failed for ZeroShotLLMOCR: {exc}. "
- "Trying next candidate if available."
- )
- continue
-
- if f"<{self.OUTPUT_TAG}>" not in llm_output:
- logger.warning(
- f"LLM output does not contain <{self.OUTPUT_TAG}> tag. Output: {llm_output}"
- )
- if current_engine in {"nanonets-ocr-s", "rolmocr"}:
- extracted_output = llm_output
- else:
- extracted_output = ""
- else:
- extracted_output = extract_tag_from_llm_output(llm_output, tags=self.OUTPUT_TAG)
-
- if extracted_output:
- break
-
- if current_engine == engines_to_try[-1]:
- break
-
- logger.warning(
- f"LLM output from '{current_engine}' is empty. Retrying with backup engine."
- )
- else:
- if last_error:
- raise LLMInferenceError(
- f"All OCR engines failed for {self.get_system_name()}"
- ) from last_error
- raise LLMInferenceError(f"No OCR output received for {self.get_system_name()}.")
-
- if not extracted_output:
- if last_error:
- raise LLMInferenceError(
- f"Failed to obtain OCR output after exhausting engines: {last_error}"
- ) from last_error
- raise LLMInferenceError(
- f"Failed to obtain OCR output for {self.get_system_name()} (empty response)."
- )
- assert isinstance(extracted_output, str)
-
- while f"<{self.OUTPUT_TAG}>" in extracted_output:
- extracted_output = extracted_output[
- extracted_output.index(f"<{self.OUTPUT_TAG}>") + len(f"<{self.OUTPUT_TAG}>") :
- ]
- if f"{self.OUTPUT_TAG}>" in extracted_output:
- extracted_output = extracted_output[: extracted_output.index(f"{self.OUTPUT_TAG}>")]
-
- return extracted_output
-
- @override
- def get_system_name(self) -> str:
- return "Zero-Shot LLM"
diff --git a/systems/mistral_ocr.py b/systems/mistral_ocr.py
deleted file mode 100644
index de88ed8..0000000
--- a/systems/mistral_ocr.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""Mistral OCR implementations."""
-
-from typing import override
-
-import mistralai
-from PIL import Image
-from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
-
-from churro.config.settings import get_settings
-from churro.utils.llm import encode_image
-
-from .base_ocr import BaseOCR
-
-
-class MistralOCR(BaseOCR):
- """Mistral OCR implementation."""
-
- def __init__(self, **kwargs: object) -> None:
- super().__init__(**kwargs)
- self._client = None
-
- def _get_client(self) -> mistralai.Mistral:
- """Get or create Mistral client."""
- if self._client is None:
- api_key = get_settings().tokens.mistral
- if not api_key:
- raise ValueError("MISTRAL_API_KEY is required to use Mistral OCR.")
- self._client = mistralai.Mistral(api_key=api_key)
- return self._client
-
- @retry(
- stop=stop_after_attempt(3),
- wait=wait_fixed(30),
- retry=retry_if_exception_type(mistralai.models.sdkerror.SDKError),
- )
- @override
- async def process_image(self, image: Image.Image) -> str:
- """Process a single image using Mistral OCR."""
- client = self._get_client()
-
- base64_image = encode_image(image)
- if base64_image is None:
- return ""
-
- image_url = f"data:image/jpeg;base64,{base64_image}"
- response = await client.ocr.process_async(
- model="mistral-ocr-latest",
- document={
- "type": "image_url",
- "image_url": image_url,
- },
- )
-
- return response.pages[0].markdown
-
- def get_system_name(self) -> str:
- """Return human-readable system name."""
- return "Mistral OCR"
diff --git a/systems/ocr_factory.py b/systems/ocr_factory.py
deleted file mode 100644
index 619f077..0000000
--- a/systems/ocr_factory.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""OCR Factory for creating OCR system instances."""
-
-from argparse import Namespace
-from importlib import import_module
-from typing import TYPE_CHECKING, Any
-
-
-if TYPE_CHECKING:
- from .base_ocr import BaseOCR
-
-
-class OCRFactory:
- """Factory class for creating OCR system instances."""
-
- _REGISTRY: dict[str, str] = {
- "azure": "churro.systems.azure_ocr:AzureOCR",
- "mistral_ocr": "churro.systems.mistral_ocr:MistralOCR",
- "llm": "churro.systems.llm_ocr:ZeroShotLLMOCR",
- "finetuned": "churro.systems.finetuned_ocr:FineTunedOCR",
- }
-
- _CACHE: dict[str, type["BaseOCR"]] = {}
-
- @classmethod
- def get_available_systems(cls) -> list[str]:
- """Get list of available OCR system names."""
- return list(cls._REGISTRY.keys())
-
- @classmethod
- def create_ocr_system(cls, args: Namespace) -> "BaseOCR":
- """Create an OCR system instance by name."""
- system_name = args.system
- if system_name not in cls._REGISTRY:
- available_systems = ", ".join(cls._REGISTRY.keys())
- raise ValueError(
- f"Invalid system: {system_name}. Available systems: {available_systems}"
- )
-
- ocr_class = cls._load_class(system_name)
- return ocr_class(**_extract_system_config(args))
-
- @classmethod
- def _load_class(cls, system_name: str) -> type["BaseOCR"]:
- if system_name in cls._CACHE:
- return cls._CACHE[system_name]
- module_path, class_name = cls._REGISTRY[system_name].split(":", 1)
- module = import_module(module_path)
- ocr_class = getattr(module, class_name)
- cls._CACHE[system_name] = ocr_class
- return ocr_class
-
-
-def _extract_system_config(args: Namespace) -> dict[str, Any]:
- """Extract configuration parameters for the OCR system from args."""
- config = {}
-
- # Common parameters
- if hasattr(args, "engine"):
- config["engine"] = args.engine
- if hasattr(args, "backup_engine"):
- config["backup_engine"] = args.backup_engine
- if hasattr(args, "max_tokens"):
- config["max_tokens"] = args.max_tokens
- if getattr(args, "system", None) == "finetuned" and hasattr(args, "strip_xml"):
- config["strip_xml"] = args.strip_xml
-
- # LLM-specific parameters
- if hasattr(args, "resize"):
- config["resize"] = args.resize
- if hasattr(args, "reasoning_effort"):
- config["reasoning_effort"] = args.reasoning_effort
- if hasattr(args, "output_markdown"):
- config["output_markdown"] = args.output_markdown
-
- # Pipeline-specific parameters
- if hasattr(args, "layout_detection_models"):
- config["layout_detection_models"] = args.layout_detection_models
- if hasattr(args, "layout_detection_num_splits"):
- config["layout_detection_num_splits"] = args.layout_detection_num_splits
- if hasattr(args, "layout_detection_num_iterations"):
- config["layout_detection_num_iterations"] = args.layout_detection_num_iterations
-
- return config
diff --git a/tests/minimal-document.pdf b/tests/assets/minimal-document.pdf
similarity index 100%
rename from tests/minimal-document.pdf
rename to tests/assets/minimal-document.pdf
diff --git a/tests/churro_dataset_sample_1.jpeg b/tests/churro_dataset_sample_1.jpeg
deleted file mode 100644
index bb3db92..0000000
Binary files a/tests/churro_dataset_sample_1.jpeg and /dev/null differ
diff --git a/tests/churro_dataset_sample_2.jpeg b/tests/churro_dataset_sample_2.jpeg
deleted file mode 100644
index 9805fef..0000000
Binary files a/tests/churro_dataset_sample_2.jpeg and /dev/null differ
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..c0310e3
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import builtins
+import sys
+from collections.abc import Callable
+from pathlib import Path
+
+import pytest
+from PIL import Image
+from typer.testing import CliRunner
+
+_TESTS_DIR = Path(__file__).resolve().parent
+_REPO_ROOT = _TESTS_DIR.parent
+_REPO_SRC_PATH = Path(__file__).resolve().parents[1] / "src"
+_REPO_SRC_PATH_STR = str(_REPO_SRC_PATH)
+
+if _REPO_SRC_PATH_STR in sys.path:
+ sys.path.remove(_REPO_SRC_PATH_STR)
+sys.path.insert(0, _REPO_SRC_PATH_STR)
+
+
+@pytest.fixture
+def cli_runner() -> CliRunner:
+ return CliRunner()
+
+
+@pytest.fixture
+def minimal_pdf_path() -> Path:
+ return _TESTS_DIR / "assets" / "minimal-document.pdf"
+
+
+@pytest.fixture
+def test_artifact_dir_path() -> Path:
+ return _REPO_ROOT / "workdir" / "test-artifacts"
+
+
+@pytest.fixture
+def write_image_file(tmp_path: Path) -> Callable[..., Path]:
+ 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",
+ ) -> Path:
+ image_path = tmp_path / filename
+ Image.new(mode, size, color=color).save(image_path)
+ return image_path
+
+ return _write_image_file
+
+
+@pytest.fixture
+def patch_import_failure(monkeypatch: pytest.MonkeyPatch) -> Callable[..., None]:
+ real_import = builtins.__import__
+
+ def _patch_import_failure(
+ *,
+ failing_name: str,
+ exception_type: type[ImportError] = ImportError,
+ ) -> None:
+ def _fake_import(
+ name: str,
+ globals: dict[str, object] | None = None,
+ locals: dict[str, object] | None = None,
+ fromlist: tuple[str, ...] = (),
+ level: int = 0,
+ ) -> object:
+ if name == failing_name:
+ raise exception_type(f"missing {failing_name}")
+ return real_import(name, globals, locals, fromlist, level)
+
+ monkeypatch.setattr(builtins, "__import__", _fake_import)
+
+ return _patch_import_failure
diff --git a/tests/test_args_module.py b/tests/test_args_module.py
deleted file mode 100644
index 222b2e3..0000000
--- a/tests/test_args_module.py
+++ /dev/null
@@ -1,85 +0,0 @@
-from __future__ import annotations
-
-from argparse import Namespace
-import builtins
-from pathlib import Path
-
-import pytest
-
-from churro import args
-
-
-@pytest.fixture
-def fake_module_root(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path:
- """Force churro.args to treat tmp_path as its module root."""
-
- def fake_resolve(self: Path) -> Path: # pragma: no cover - exercised via create_output_prefix
- return tmp_path / "module.py"
-
- monkeypatch.setattr(args.Path, "resolve", fake_resolve, raising=False)
- return tmp_path
-
-
-def test_validate_args_requires_engine(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setattr(args, "MODEL_MAP", {"valid-engine": object()})
- with pytest.raises(AssertionError, match="LLM engine must be specified"):
- args._validate_args(Namespace(system="llm", engine=None))
-
-
-def test_validate_args_rejects_unknown_engine(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setattr(args, "MODEL_MAP", {"valid-engine": object()})
- with pytest.raises(AssertionError, match="Invalid engine"):
- args._validate_args(Namespace(system="llm", engine="unknown"))
-
-
-def test_create_output_prefix_creates_directory(
- fake_module_root: Path,
-) -> None:
- target = Namespace(system="azure", engine=None, dataset_split="dev")
- output = Path(args.create_output_prefix(target))
- expected = fake_module_root / "workdir" / "results" / "dev" / "azure"
- assert output == expected
- assert output.exists()
-
-
-def test_create_output_prefix_aborts_non_interactive(
- fake_module_root: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
- output_dir = fake_module_root / "workdir" / "results" / "dev" / "azure"
- output_dir.mkdir(parents=True, exist_ok=True)
- (output_dir / "existing.txt").write_text("keep-me")
-
- class DummyStdIn:
- def isatty(self) -> bool:
- return False
-
- monkeypatch.setattr(args.sys, "stdin", DummyStdIn())
-
- target = Namespace(system="azure", engine=None, dataset_split="dev")
- with pytest.raises(SystemExit):
- args.create_output_prefix(target)
-
- assert (output_dir / "existing.txt").exists()
-
-
-def test_create_output_prefix_allows_interactive_overwrite(
- fake_module_root: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
- output_dir = fake_module_root / "workdir" / "results" / "dev" / "azure"
- output_dir.mkdir(parents=True, exist_ok=True)
- existing_file = output_dir / "existing.txt"
- existing_file.write_text("old")
-
- class TtyStdIn:
- def isatty(self) -> bool:
- return True
-
- monkeypatch.setattr(args.sys, "stdin", TtyStdIn())
- monkeypatch.setattr(builtins, "input", lambda _: "y")
-
- target = Namespace(system="azure", engine=None, dataset_split="dev")
- result = Path(args.create_output_prefix(target))
-
- assert result == output_dir
- assert output_dir.exists()
- assert not existing_file.exists()
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..a6e6715
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,227 @@
+from __future__ import annotations
+
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+from PIL import Image
+
+import churro_ocr.cli as cli_module
+from churro_ocr.cli import app
+from churro_ocr.ocr import OCRResult
+from churro_ocr.page_detection import DocumentPage, PageDetectionResult
+from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG
+from churro_ocr.templates import DEFAULT_OCR_TEMPLATE, DOTS_OCR_1_5_OCR_TEMPLATE
+
+
+def test_transcribe_cli_writes_output(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ cli_runner,
+ write_image_file,
+) -> None:
+ image_path = write_image_file(size=(10, 10))
+ output_path = tmp_path / "out.txt"
+
+ class _FakeBackend:
+ async def ocr(self, page): # noqa: ANN001
+ image = page.image
+ return OCRResult(
+ text=f"ocr:{image.width}x{image.height}",
+ provider_name="fake",
+ model_name="fake-model",
+ )
+
+ monkeypatch.setattr("churro_ocr.cli._build_ocr_backend", lambda **_: _FakeBackend())
+
+ result = cli_runner.invoke(
+ app,
+ [
+ "transcribe",
+ "--image",
+ str(image_path),
+ "--output",
+ str(output_path),
+ ],
+ )
+
+ assert result.exit_code == 0
+ assert output_path.read_text() == "ocr:10x10"
+
+
+def test_extract_pages_cli_writes_page_images(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ cli_runner,
+ write_image_file,
+) -> None:
+ image_path = write_image_file(size=(10, 10))
+ output_dir = tmp_path / "pages"
+
+ class _FakePageDetector:
+ def __init__(self, **_: object) -> None:
+ pass
+
+ def detect_image_sync(self, request): # noqa: ANN001
+ _ = request.require_image()
+ return PageDetectionResult(
+ pages=[
+ DocumentPage(
+ page_index=0,
+ image=Image.new("RGB", (8, 8), color="white"),
+ source_index=0,
+ )
+ ],
+ source_type="image",
+ )
+
+ monkeypatch.setattr("churro_ocr.cli.DocumentPageDetector", _FakePageDetector)
+
+ result = cli_runner.invoke(
+ app,
+ [
+ "extract-pages",
+ "--image",
+ str(image_path),
+ "--output-dir",
+ str(output_dir),
+ ],
+ )
+
+ assert result.exit_code == 0
+ assert (output_dir / "page_0000.png").exists()
+
+
+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,
+ )
+ vllm_backend = cli_module._build_ocr_backend(
+ backend="vllm",
+ model="example/model",
+ endpoint=None,
+ api_key=None,
+ base_url=None,
+ api_version=None,
+ )
+
+ assert litellm_backend.template == DEFAULT_OCR_TEMPLATE
+ assert litellm_backend.template == hf_backend.template == vllm_backend.template
+ assert litellm_backend.model_name == "example/model"
+ assert hf_backend.model_name == "example/model"
+ assert vllm_backend.model_name == "example/model"
+ assert f"<{DEFAULT_OCR_OUTPUT_TAG}>" in litellm_backend.template.system_message
+ assert f"{DEFAULT_OCR_OUTPUT_TAG}>" in litellm_backend.template.system_message
+ assert f"<{DEFAULT_OCR_OUTPUT_TAG}>" in litellm_backend.template.user_prompt
+ assert f"{DEFAULT_OCR_OUTPUT_TAG}>" in litellm_backend.template.user_prompt
+
+
+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,
+ )
+ vllm_backend = cli_module._build_ocr_backend(
+ backend="vllm",
+ model="kristaller486/dots.ocr-1.5",
+ endpoint=None,
+ api_key=None,
+ base_url=None,
+ api_version=None,
+ )
+
+ assert litellm_backend.template == DOTS_OCR_1_5_OCR_TEMPLATE
+ assert litellm_backend.template == hf_backend.template == vllm_backend.template
+ assert litellm_backend.model_name == "dots.ocr-1.5"
+ assert hf_backend.model_name == "dots.ocr-1.5"
+ assert vllm_backend.model_name == "dots.ocr-1.5"
+
+
+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,
+ )
+ vllm_backend = cli_module._build_ocr_backend(
+ backend="vllm",
+ model="Qwen/Qwen3.5-0.8B",
+ endpoint=None,
+ api_key=None,
+ base_url=None,
+ api_version=None,
+ )
+
+ assert litellm_backend.template == DEFAULT_OCR_TEMPLATE
+ assert litellm_backend.template == hf_backend.template == vllm_backend.template
+ assert litellm_backend.model_name == "Qwen/Qwen3.5-0.8B"
+ assert hf_backend.model_name == "Qwen/Qwen3.5-0.8B"
+ assert vllm_backend.model_name == "Qwen/Qwen3.5-0.8B"
+ assert vllm_backend.llm_kwargs == {}
+
+
+def test_module_entrypoint_help() -> None:
+ result = subprocess.run(
+ [sys.executable, "-m", "churro_ocr", "--help"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0
+ assert "transcribe" in result.stdout
+ assert "extract-pages" in result.stdout
+
+
+def test_console_script_help() -> None:
+ executable = shutil.which("churro-ocr")
+ assert executable is not None
+
+ result = subprocess.run(
+ [executable, "--help"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ assert result.returncode == 0
+ assert "transcribe" in result.stdout
+ assert "extract-pages" in result.stdout
diff --git a/tests/test_cli_benchmark_unit.py b/tests/test_cli_benchmark_unit.py
deleted file mode 100644
index 7a0e350..0000000
--- a/tests/test_cli_benchmark_unit.py
+++ /dev/null
@@ -1,234 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Iterator
-import contextlib
-from pathlib import Path
-from types import SimpleNamespace
-
-from PIL import Image
-import pytest
-
-from churro.cli import benchmark
-
-
-@pytest.fixture(autouse=True)
-def stub_model_map(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setattr(benchmark, "MODEL_MAP", {"valid-engine": object()})
-
-
-def test_validate_options_requires_engine() -> None:
- options = benchmark.BenchmarkOptions(
- system="llm",
- engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- resize=None,
- max_concurrency=1,
- input_size=1,
- dataset_split="dev",
- offset=0,
- )
- assert benchmark._validate_options(options) == 1
-
-
-def test_validate_options_rejects_invalid_split() -> None:
- options = benchmark.BenchmarkOptions(
- system="azure",
- engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- resize=None,
- max_concurrency=1,
- input_size=1,
- dataset_split="train",
- offset=0,
- )
- assert benchmark._validate_options(options) == 1
-
-
-@pytest.mark.asyncio
-async def test_run_executes_pipeline(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- dataset = [
- {"image": "img0", "file_name": "file0"},
- {"image": "img1", "file_name": "file1"},
- {"image": "img2", "file_name": "file2"},
- ]
-
- def fake_load_dataset(dataset_id: str, split: str, streaming: bool) -> list[dict[str, str]]:
- assert dataset_id == benchmark.CHURRO_DATASET_ID
- assert split == "dev"
- assert streaming is True
- return dataset
-
- monkeypatch.setattr(benchmark, "load_dataset", fake_load_dataset)
- monkeypatch.setattr(benchmark, "create_output_prefix", lambda _: str(tmp_path))
-
- time_values = iter([10.0, 13.5])
- monkeypatch.setattr(benchmark, "time", lambda: next(time_values))
-
- class FakeOCR:
- async def process_images(self, images: list[str], max_concurrency: int) -> list[str]:
- assert images == ["img1"]
- assert max_concurrency == 2
- return ["prediction"]
-
- monkeypatch.setattr(benchmark.OCRFactory, "create_ocr_system", lambda _: FakeOCR())
-
- container_calls: list[dict[str, object]] = []
-
- @contextlib.contextmanager
- def fake_managed_container(**kwargs: object) -> Iterator[SimpleNamespace]:
- container_calls.append(kwargs)
- yield SimpleNamespace()
-
- monkeypatch.setattr(benchmark, "managed_vllm_container", fake_managed_container)
-
- captured = {}
-
- def fake_compute_metrics(
- ds: list[dict[str, str]],
- predictions: list[str],
- output_prefix: str,
- elapsed_time: float,
- ) -> dict[str, str]:
- captured["dataset"] = ds
- captured["predictions"] = predictions
- captured["output_prefix"] = output_prefix
- captured["elapsed_time"] = elapsed_time
- return {"status": "ok"}
-
- monkeypatch.setattr(benchmark, "compute_metrics", fake_compute_metrics)
-
- options = benchmark.BenchmarkOptions(
- system="azure",
- engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- resize=None,
- max_concurrency=2,
- input_size=1,
- dataset_split="dev",
- offset=1,
- )
-
- result = await benchmark.run(options)
-
- assert result == 0
- assert container_calls == [
- {
- "engine": None,
- "backup_engine": None,
- "system": "azure",
- "tensor_parallel_size": 1,
- "data_parallel_size": 1,
- }
- ]
- assert captured["dataset"] == [{"image": "img1", "file_name": "file1"}]
- assert captured["predictions"] == ["prediction"]
- assert captured["output_prefix"] == str(tmp_path)
- assert captured["elapsed_time"] == pytest.approx(3.5)
-
-
-@pytest.mark.asyncio
-async def test_run_binarizes_images(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- dataset = [
- {"image": Image.new("RGB", (8, 8), color="white"), "file_name": "file0"},
- {"image": Image.new("RGB", (8, 8), color="white"), "file_name": "file1"},
- ]
-
- def fake_load_dataset(dataset_id: str, split: str, streaming: bool) -> list[dict[str, object]]:
- assert dataset_id == benchmark.CHURRO_DATASET_ID
- assert split == "dev"
- assert streaming is True
- return dataset
-
- monkeypatch.setattr(benchmark, "load_dataset", fake_load_dataset)
- monkeypatch.setattr(benchmark, "create_output_prefix", lambda _: str(tmp_path))
-
- time_values = iter([5.0, 7.0])
- monkeypatch.setattr(benchmark, "time", lambda: next(time_values))
-
- class FakeBinarizer:
- def __init__(self) -> None:
- self.calls = 0
-
- def binarize_pil_batch(
- self,
- images: list[Image.Image],
- scale: float = 1.0,
- n_batch_inference: int = 16,
- ) -> list[Image.Image]:
- del scale, n_batch_inference
- self.calls += 1
- assert len(images) == 1
- return [Image.new("L", images[0].size, color=0)]
-
- fake_binarizer = FakeBinarizer()
-
- class FakeOCR:
- async def process_images(
- self, images: list[Image.Image], max_concurrency: int
- ) -> list[str]:
- assert len(images) == 1
- assert images[0].mode == "L"
- assert max_concurrency == 4
- return ["binary prediction"]
-
- monkeypatch.setattr(benchmark.OCRFactory, "create_ocr_system", lambda _: FakeOCR())
- monkeypatch.setattr(benchmark, "ImageBinarizer", lambda: fake_binarizer)
-
- container_calls: list[dict[str, object]] = []
-
- @contextlib.contextmanager
- def fake_managed_container(**kwargs: object) -> Iterator[SimpleNamespace]:
- container_calls.append(kwargs)
- yield SimpleNamespace()
-
- monkeypatch.setattr(benchmark, "managed_vllm_container", fake_managed_container)
-
- captured = {}
-
- def fake_compute_metrics(
- ds: list[dict[str, object]],
- predictions: list[str],
- output_prefix: str,
- elapsed_time: float,
- ) -> dict[str, str]:
- captured["dataset"] = ds
- captured["predictions"] = predictions
- captured["output_prefix"] = output_prefix
- captured["elapsed_time"] = elapsed_time
- return {"status": "ok"}
-
- monkeypatch.setattr(benchmark, "compute_metrics", fake_compute_metrics)
-
- options = benchmark.BenchmarkOptions(
- system="azure",
- engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- resize=None,
- max_concurrency=4,
- input_size=1,
- dataset_split="dev",
- offset=0,
- binarize=True,
- )
-
- result = await benchmark.run(options)
-
- assert result == 0
- assert fake_binarizer.calls == 1
- assert container_calls == [
- {
- "engine": None,
- "backup_engine": None,
- "system": "azure",
- "tensor_parallel_size": 1,
- "data_parallel_size": 1,
- }
- ]
- assert captured["dataset"] == [dataset[0]]
- assert captured["predictions"] == ["binary prediction"]
- assert captured["output_prefix"] == str(tmp_path)
- assert captured["elapsed_time"] == pytest.approx(2.0)
diff --git a/tests/test_cli_contract.py b/tests/test_cli_contract.py
new file mode 100644
index 0000000..0bffed9
--- /dev/null
+++ b/tests/test_cli_contract.py
@@ -0,0 +1,245 @@
+from __future__ import annotations
+
+import runpy
+from pathlib import Path
+
+import pytest
+from PIL import Image
+
+import churro_ocr.cli as cli_module
+from churro_ocr.cli import app
+from churro_ocr.ocr import OCRResult
+from churro_ocr.page_detection import DocumentPage, PageDetectionResult
+
+
+@pytest.fixture
+def sample_image_path(write_image_file) -> Path:
+ return write_image_file(size=(12, 12))
+
+
+@pytest.mark.parametrize(
+ ("args", "expected_parts"),
+ [
+ (["--backend", "litellm"], ("--model is required for backend=litellm",)),
+ (
+ ["--backend", "openai-compatible", "--model", "local-model"],
+ ("required for", "backend=openai-compatible"),
+ ),
+ (["--backend", "azure"], ("--endpoint and --api-key are required for backend=azure",)),
+ (["--backend", "mistral"], ("--api-key is required for backend=mistral",)),
+ (["--backend", "hf"], ("--model is required for backend=hf",)),
+ (["--backend", "vllm"], ("--model is required for backend=vllm",)),
+ ],
+)
+def test_transcribe_cli_validates_backend_requirements(
+ sample_image_path: Path,
+ args: list[str],
+ expected_parts: tuple[str, ...],
+ cli_runner,
+) -> None:
+ result = cli_runner.invoke(
+ app,
+ ["transcribe", "--image", str(sample_image_path), *args],
+ )
+
+ assert result.exit_code != 0
+ output = " ".join(result.output.split())
+ for expected_part in expected_parts:
+ assert expected_part in output
+
+
+def test_transcribe_cli_rejects_unsupported_backend(sample_image_path: Path, cli_runner) -> None:
+ result = cli_runner.invoke(
+ app,
+ [
+ "transcribe",
+ "--image",
+ str(sample_image_path),
+ "--backend",
+ "unsupported",
+ "--model",
+ "example/model",
+ ],
+ )
+
+ assert result.exit_code != 0
+ assert "Unsupported backend: unsupported" in result.output
+
+
+def test_transcribe_cli_echoes_text_without_output(
+ monkeypatch: pytest.MonkeyPatch,
+ sample_image_path: Path,
+ cli_runner,
+) -> None:
+ class _FakeBackend:
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ return OCRResult(
+ text=f"plain:{page.image.width}x{page.image.height}",
+ provider_name="fake",
+ model_name="fake-model",
+ )
+
+ monkeypatch.setattr("churro_ocr.cli._build_ocr_backend", lambda **_: _FakeBackend())
+
+ result = cli_runner.invoke(
+ app,
+ [
+ "transcribe",
+ "--image",
+ str(sample_image_path),
+ ],
+ )
+
+ assert result.exit_code == 0
+ assert result.output.strip() == "plain:12x12"
+
+
+@pytest.mark.parametrize(
+ "command_args",
+ [
+ ["extract-pages", "--output-dir", "unused"],
+ [
+ "extract-pages",
+ "--image",
+ "scan.png",
+ "--pdf",
+ "document.pdf",
+ "--output-dir",
+ "unused",
+ ],
+ ],
+)
+def test_extract_pages_cli_requires_exactly_one_image_or_pdf(
+ sample_image_path: Path,
+ tmp_path: Path,
+ command_args: list[str],
+ minimal_pdf_path: Path,
+ cli_runner,
+) -> None:
+ output_dir = tmp_path / "pages"
+ args = [
+ str(sample_image_path)
+ if token == "scan.png"
+ else str(minimal_pdf_path)
+ if token == "document.pdf"
+ else token
+ for token in command_args
+ ]
+ args = [str(output_dir) if token == "unused" else token for token in args]
+
+ result = cli_runner.invoke(app, args)
+
+ assert result.exit_code != 0
+ assert "Provide exactly one of --image or --pdf." in result.output
+
+
+@pytest.mark.parametrize(
+ ("args", "expected_parts"),
+ [
+ (
+ ["--page-detector", "llm"],
+ ("--model is required when --page-detector=llm",),
+ ),
+ (
+ ["--page-detector", "azure"],
+ ("required when", "--page-detector=azure"),
+ ),
+ ],
+)
+def test_extract_pages_cli_validates_page_detector_requirements(
+ sample_image_path: Path,
+ tmp_path: Path,
+ args: list[str],
+ expected_parts: tuple[str, ...],
+ cli_runner,
+) -> None:
+ output_dir = tmp_path / "pages"
+ result = cli_runner.invoke(
+ app,
+ [
+ "extract-pages",
+ "--image",
+ str(sample_image_path),
+ "--output-dir",
+ str(output_dir),
+ *args,
+ ],
+ )
+
+ assert result.exit_code != 0
+ output = " ".join(result.output.split())
+ for expected_part in expected_parts:
+ assert expected_part in output
+
+
+def test_extract_pages_cli_writes_pdf_page_images(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+ minimal_pdf_path: Path,
+ cli_runner,
+) -> None:
+ calls: dict[str, object] = {}
+
+ class _FakePageDetector:
+ 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}")
+
+ def detect_pdf_sync(self, path: Path, *, dpi: int, trim_margin: int) -> PageDetectionResult:
+ calls["path"] = Path(path)
+ calls["dpi"] = dpi
+ calls["trim_margin"] = trim_margin
+ return PageDetectionResult(
+ pages=[
+ DocumentPage(
+ page_index=0,
+ image=Image.new("RGB", (8, 8), color="white"),
+ source_index=0,
+ )
+ ],
+ source_type="pdf",
+ )
+
+ monkeypatch.setattr("churro_ocr.cli.DocumentPageDetector", _FakePageDetector)
+
+ output_dir = tmp_path / "pages"
+ result = cli_runner.invoke(
+ app,
+ [
+ "extract-pages",
+ "--pdf",
+ str(minimal_pdf_path),
+ "--output-dir",
+ str(output_dir),
+ "--dpi",
+ "150",
+ "--trim-margin",
+ "0",
+ ],
+ )
+
+ output_path = output_dir / "page_0000.png"
+ assert result.exit_code == 0
+ assert output_path.exists()
+ assert str(output_path) in result.output
+ assert calls == {
+ "backend": None,
+ "path": minimal_pdf_path,
+ "dpi": 150,
+ "trim_margin": 0,
+ }
+
+
+def test_module_main_invokes_cli_main(monkeypatch: pytest.MonkeyPatch) -> None:
+ calls = {"count": 0}
+
+ def _fake_main() -> None:
+ calls["count"] += 1
+
+ monkeypatch.setattr(cli_module, "main", _fake_main)
+
+ runpy.run_module("churro_ocr.__main__", run_name="__main__")
+
+ assert calls["count"] == 1
diff --git a/tests/test_cli_end_to_end.py b/tests/test_cli_end_to_end.py
deleted file mode 100644
index 88a4387..0000000
--- a/tests/test_cli_end_to_end.py
+++ /dev/null
@@ -1,142 +0,0 @@
-from __future__ import annotations
-
-import argparse
-from collections.abc import Sequence
-from pathlib import Path
-from typing import cast
-
-import pytest
-
-from churro.cli.docs_to_images import DocsToImagesOptions
-from churro.cli.main import app
-
-
-def test_docs_to_images_dry_run(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- captured: dict[str, DocsToImagesOptions] = {}
-
- async def fake_run(options: DocsToImagesOptions) -> int:
- captured["options"] = options
- return 0
-
- monkeypatch.setattr("churro.cli.docs_to_images.run", fake_run)
-
- (tmp_path / "inputs").mkdir()
- (tmp_path / "inputs" / "sample.pdf").write_bytes(b"%PDF-1.4")
-
- exit_code = app(
- [
- "docs-to-images",
- "--input-dir",
- str(tmp_path / "inputs"),
- "--output-dir",
- str(tmp_path / "out"),
- "--suffix",
- "pdf",
- "--suffix",
- "PNG",
- "--dry-run",
- ],
- standalone_mode=False,
- )
-
- assert exit_code == 0
- options = cast(DocsToImagesOptions, captured["options"])
- assert options.dry_run is True
- assert options.pattern == "*"
- assert options.extensions == [".pdf", ".png"]
- assert options.dpi is None
-
-
-def test_infer_invokes_ocr_factory(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- image_path = tmp_path / "page.png"
- from PIL import Image
-
- Image.new("RGB", (10, 10), color="white").save(image_path)
-
- class DummyOCR:
- async def process_images_from_files(
- self, paths: Sequence[str], max_concurrency: int
- ) -> list[str]:
- assert paths == [str(image_path)]
- return ["dummy"]
-
- captured: dict[str, argparse.Namespace] = {}
-
- def capture_and_build(args: argparse.Namespace) -> DummyOCR:
- captured["options"] = args
- return DummyOCR()
-
- monkeypatch.setattr(
- "churro.systems.ocr_factory.OCRFactory.create_ocr_system",
- capture_and_build,
- )
-
- exit_code = app(
- [
- "infer",
- "--system",
- "azure",
- "--image",
- str(image_path),
- ],
- standalone_mode=False,
- )
-
- assert exit_code == 0
- options = captured["options"]
- assert options.suffixes == [".png"]
-
-
-def test_benchmark_invokes_metrics(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
- from PIL import Image
-
- image_path = tmp_path / "img.png"
- Image.new("RGB", (10, 10), color="white").save(image_path)
- sample: list[dict[str, Image.Image]] = [{"image": Image.open(image_path)}]
-
- def load_dataset_stub(
- dataset_id: str, split: str, streaming: bool
- ) -> list[dict[str, Image.Image]]:
- return sample
-
- monkeypatch.setattr("churro.cli.benchmark.load_dataset", load_dataset_stub)
-
- class DummyOCR:
- async def process_images(
- self, images: Sequence[Image.Image], max_concurrency: int
- ) -> list[str]:
- return ["text"] * len(images)
-
- def create_ocr_system_stub(args: argparse.Namespace) -> DummyOCR:
- return DummyOCR()
-
- def compute_metrics_stub(
- dataset: Sequence[dict[str, bytes]],
- texts: Sequence[str],
- prefix: str,
- elapsed_time: float,
- ) -> None:
- return None
-
- monkeypatch.setattr(
- "churro.systems.ocr_factory.OCRFactory.create_ocr_system",
- create_ocr_system_stub,
- )
- monkeypatch.setattr("churro.cli.benchmark.compute_metrics", compute_metrics_stub)
-
- exit_code = app(
- [
- "benchmark",
- "--system",
- "azure",
- "--dataset-split",
- "dev",
- "--input-size",
- "1",
- "--engine",
- "azure",
- ],
- standalone_mode=False,
- )
-
- assert exit_code == 0
diff --git a/tests/test_cli_infer_unit.py b/tests/test_cli_infer_unit.py
deleted file mode 100644
index 35947de..0000000
--- a/tests/test_cli_infer_unit.py
+++ /dev/null
@@ -1,348 +0,0 @@
-from __future__ import annotations
-
-from collections.abc import Iterator
-import contextlib
-from pathlib import Path
-from types import SimpleNamespace
-
-from PIL import Image
-import pytest
-
-from churro.cli import infer
-
-
-@pytest.fixture(autouse=True)
-def stub_model_map(monkeypatch: pytest.MonkeyPatch) -> None:
- """Ensure MODEL_MAP has predictable contents for validation tests."""
- monkeypatch.setattr(infer, "MODEL_MAP", {"valid-engine": object()})
-
-
-def test_validate_options_requires_engine(monkeypatch: pytest.MonkeyPatch) -> None:
- options = infer.InferOptions(
- system="llm",
- engine=None,
- backup_engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- image=None,
- image_dir=None,
- pattern="*.png",
- suffixes=[".png"],
- recursive=False,
- output_dir=None,
- skip_existing=False,
- max_concurrency=1,
- )
- assert infer._validate_options(options) == 1
-
-
-def test_validate_options_rejects_invalid_backup_engine() -> None:
- options = infer.InferOptions(
- system="azure",
- engine=None,
- backup_engine="not-real",
- tensor_parallel_size=1,
- data_parallel_size=1,
- image=None,
- image_dir=None,
- pattern="*.png",
- suffixes=[".png"],
- recursive=False,
- output_dir=None,
- skip_existing=False,
- max_concurrency=1,
- )
- assert infer._validate_options(options) == 1
-
-
-def test_validate_options_filters_invalid_suffixes() -> None:
- options = infer.InferOptions(
- system="azure",
- engine=None,
- backup_engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- image=None,
- image_dir=None,
- pattern="*.png",
- suffixes=[".png", ".unsupported"],
- recursive=False,
- output_dir=None,
- skip_existing=False,
- max_concurrency=1,
- )
- result = infer._validate_options(options)
- assert result == 0
- assert options.suffixes == [".png"]
-
-
-def test_validate_options_errors_when_suffixes_removed() -> None:
- options = infer.InferOptions(
- system="azure",
- engine=None,
- backup_engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- image=None,
- image_dir=None,
- pattern="*.png",
- suffixes=[".unsupported"],
- recursive=False,
- output_dir=None,
- skip_existing=False,
- max_concurrency=1,
- )
- assert infer._validate_options(options) == 1
-
-
-def test_collect_images_deduplicates_and_filters(tmp_path: Path) -> None:
- (tmp_path / "keep.png").write_text("one")
- (tmp_path / "skip.txt").write_text("two")
- nested = tmp_path / "nested"
- nested.mkdir()
- (nested / "keep2.png").write_text("three")
- duplicate = nested / "keep.png"
- duplicate.write_text("four")
-
- images = infer._collect_images(
- image=None,
- image_dir=tmp_path,
- suffixes=[".png"],
- recursive=True,
- )
- assert len(images) == 3
- assert {path.resolve() for path in images} == {
- (tmp_path / "keep.png").resolve(),
- (nested / "keep2.png").resolve(),
- duplicate.resolve(),
- }
-
-
-def test_write_output_skips_existing(tmp_path: Path) -> None:
- out_dir = tmp_path / "out"
- out_dir.mkdir()
- target = out_dir / "image.txt"
- target.write_text("original")
- image_path = tmp_path / "image.png"
- image_path.write_text("img")
-
- infer._write_or_print_output(
- img_path=image_path,
- text="new",
- output_dir=out_dir,
- skip_existing=True,
- multi_mode=False,
- )
- assert target.read_text() == "original"
-
-
-@pytest.mark.asyncio
-async def test_run_requires_image_selection(tmp_path: Path) -> None:
- options = infer.InferOptions(
- system="azure",
- engine=None,
- backup_engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- image=None,
- image_dir=None,
- pattern="*.png",
- suffixes=[".png"],
- recursive=False,
- output_dir=None,
- skip_existing=False,
- max_concurrency=1,
- )
- result = await infer.run(options)
- assert result == 1
-
-
-@pytest.mark.asyncio
-async def test_run_rejects_image_and_directory(tmp_path: Path) -> None:
- image_path = tmp_path / "image.png"
- image_path.write_text("img")
- options = infer.InferOptions(
- system="azure",
- engine=None,
- backup_engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- image=image_path,
- image_dir=tmp_path,
- pattern="*.png",
- suffixes=[".png"],
- recursive=False,
- output_dir=None,
- skip_existing=False,
- max_concurrency=1,
- )
- result = await infer.run(options)
- assert result == 1
-
-
-@pytest.mark.asyncio
-async def test_run_returns_error_when_no_images_found(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
- options = infer.InferOptions(
- system="azure",
- engine=None,
- backup_engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- image=None,
- image_dir=tmp_path,
- pattern="*.png",
- suffixes=[".png"],
- recursive=False,
- output_dir=None,
- skip_existing=False,
- max_concurrency=1,
- )
-
- monkeypatch.setattr(infer, "_collect_images", lambda *_, **__: [])
-
- result = await infer.run(options)
- assert result == 1
-
-
-@pytest.mark.asyncio
-async def test_run_processes_images_and_writes_outputs(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
- image_dir = tmp_path / "images"
- image_dir.mkdir()
- first = image_dir / "img1.png"
- second = image_dir / "img2.png"
- first.write_text("a")
- second.write_text("b")
-
- options = infer.InferOptions(
- system="azure",
- engine=None,
- backup_engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- image=None,
- image_dir=image_dir,
- pattern="*.png",
- suffixes=[".png"],
- recursive=False,
- output_dir=tmp_path / "outputs",
- skip_existing=False,
- max_concurrency=0,
- )
-
- class FakeOCR:
- async def process_images_from_files(
- self, image_paths: list[str], max_concurrency: int
- ) -> list[str]:
- assert image_paths == [str(first), str(second)]
- assert max_concurrency == 1 # coerced from 0
- return ["first text", "second text"]
-
- container_calls: list[dict[str, object]] = []
-
- @contextlib.contextmanager
- def fake_managed_container(**kwargs: object) -> Iterator[SimpleNamespace]:
- container_calls.append(kwargs)
- yield SimpleNamespace()
-
- monkeypatch.setattr(infer, "managed_vllm_container", fake_managed_container)
- monkeypatch.setattr(infer.OCRFactory, "create_ocr_system", lambda _: FakeOCR())
-
- result = await infer.run(options)
-
- assert result == 0
- assert container_calls == [
- {
- "engine": None,
- "backup_engine": None,
- "system": "azure",
- "tensor_parallel_size": 1,
- "data_parallel_size": 1,
- }
- ]
-
- out_dir = options.output_dir
- assert out_dir is not None
- assert (out_dir / "img1.txt").read_text() == "first text"
- assert (out_dir / "img2.txt").read_text() == "second text"
-
-
-@pytest.mark.asyncio
-async def test_run_binarizes_inputs_before_ocr(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
- image_dir = tmp_path / "images"
- image_dir.mkdir()
- first = image_dir / "img1.png"
- second = image_dir / "img2.png"
- Image.new("RGB", (10, 10), color="white").save(first)
- Image.new("RGB", (10, 10), color="white").save(second)
-
- options = infer.InferOptions(
- system="azure",
- engine=None,
- backup_engine=None,
- tensor_parallel_size=1,
- data_parallel_size=1,
- image=None,
- image_dir=image_dir,
- pattern="*.png",
- suffixes=[".png"],
- recursive=False,
- output_dir=tmp_path / "outputs",
- skip_existing=False,
- max_concurrency=2,
- binarize=True,
- )
-
- class FakeBinarizer:
- def __init__(self) -> None:
- self.calls: list[tuple[int, int]] = []
-
- def binarize_pil_batch(
- self, images: list[Image.Image], scale: float = 1.0, n_batch_inference: int = 16
- ) -> list[Image.Image]:
- del scale, n_batch_inference # unused in fake implementation
- self.calls.extend(image.size for image in images)
- return [Image.new("L", image.size, color=0) for image in images]
-
- fake_binarizer = FakeBinarizer()
-
- class FakeOCR:
- async def process_images(
- self, images: list[Image.Image], *, max_concurrency: int
- ) -> list[str]:
- assert len(images) == 2
- assert all(isinstance(image, Image.Image) for image in images)
- assert all(image.mode == "L" for image in images)
- assert max_concurrency == 2
- return ["binarized 1", "binarized 2"]
-
- async def process_images_from_files(
- self, image_paths: list[str], max_concurrency: int
- ) -> list[str]: # pragma: no cover - defensive guard
- raise AssertionError("process_images_from_files should not be called when binarizing")
-
- container_calls: list[dict[str, object]] = []
-
- @contextlib.contextmanager
- def fake_managed_container(**kwargs: object) -> Iterator[SimpleNamespace]:
- container_calls.append(kwargs)
- yield SimpleNamespace()
-
- monkeypatch.setattr(infer, "ImageBinarizer", lambda: fake_binarizer)
- monkeypatch.setattr(infer, "managed_vllm_container", fake_managed_container)
- monkeypatch.setattr(infer.OCRFactory, "create_ocr_system", lambda _: FakeOCR())
-
- result = await infer.run(options)
-
- assert result == 0
- assert fake_binarizer.calls == [(10, 10), (10, 10)]
-
- out_dir = options.output_dir
- assert out_dir is not None
- assert (out_dir / "img1.txt").read_text() == "binarized 1"
- assert (out_dir / "img2.txt").read_text() == "binarized 2"
diff --git a/tests/test_detect_layout_cache.py b/tests/test_detect_layout_cache.py
deleted file mode 100644
index ec70a7e..0000000
--- a/tests/test_detect_layout_cache.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""Tests for Azure Document Intelligence caching in detect_layout."""
-
-from __future__ import annotations
-
-from pathlib import Path
-from types import SimpleNamespace
-
-from diskcache import Cache
-from PIL import Image
-import pytest
-
-from churro.systems import detect_layout
-
-
-class _StubPoller:
- def __init__(self, content: str) -> None:
- self._result = SimpleNamespace(content=content)
-
- async def result(self) -> SimpleNamespace:
- return self._result
-
-
-class _StubDocumentIntelligenceClient:
- def __init__(self, content: str) -> None:
- self.call_count = 0
- self._content = content
-
- async def begin_analyze_document(self, *args: object, **kwargs: object) -> _StubPoller:
- """Return a stub poller that yields the preconfigured content."""
- self.call_count += 1
- return _StubPoller(self._content)
-
-
-@pytest.mark.asyncio
-async def test_run_azure_document_analysis_on_image_uses_diskcache(
- monkeypatch: pytest.MonkeyPatch,
- tmp_path: Path,
-) -> None:
- """First call should reach Azure client, second call should hit disk cache."""
- original_client = detect_layout.azure_document_intelligence_client
- original_cache = detect_layout.azure_di_cache
- original_total_cost = detect_layout.total_azure_cost
-
- cache_dir = tmp_path / "azure-di-cache"
- cache = Cache(str(cache_dir))
- stub_client = _StubDocumentIntelligenceClient(content="cached result")
-
- monkeypatch.setattr(
- detect_layout, "DocumentIntelligenceClient", _StubDocumentIntelligenceClient
- )
- monkeypatch.setattr(detect_layout, "adjust_image", lambda image, thresholding: image)
- monkeypatch.setattr(detect_layout, "_ensure_azure_di_initialized", lambda: None)
-
- detect_layout.azure_document_intelligence_client = stub_client
- detect_layout.azure_di_cache = cache
-
- try:
- image = Image.new("RGB", (32, 32), color="white")
-
- first_result = await detect_layout.run_azure_document_analysis_on_image(
- image=image,
- skip_paragraphs=True,
- output_ocr_text=True,
- )
- second_result = await detect_layout.run_azure_document_analysis_on_image(
- image=image,
- skip_paragraphs=True,
- output_ocr_text=True,
- )
-
- assert first_result == "cached result"
- assert second_result == "cached result"
- assert stub_client.call_count == 1, "Second invocation should be served from diskcache"
- assert detect_layout.total_azure_cost - original_total_cost == pytest.approx(
- detect_layout.AZURE_DI_COST_PER_PAGE_USD
- )
- finally:
- detect_layout.azure_document_intelligence_client = original_client
- detect_layout.azure_di_cache = original_cache
- detect_layout.total_azure_cost = original_total_cost
- cache.close()
diff --git a/tests/test_document_pipeline.py b/tests/test_document_pipeline.py
new file mode 100644
index 0000000..b83b735
--- /dev/null
+++ b/tests/test_document_pipeline.py
@@ -0,0 +1,96 @@
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+from PIL import Image
+
+from churro_ocr.document import DocumentOCRPipeline
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.ocr import OCRBackend, OCRResult
+from churro_ocr.page_detection import DocumentPage, PageCandidate, PageDetectionRequest
+
+
+class _EchoOCRBackend(OCRBackend):
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ image = page.image
+ return OCRResult(
+ text=f"{page.metadata['page_index']}:{image.width}x{image.height}",
+ provider_name="echo",
+ model_name="echo-model",
+ metadata=dict(page.metadata),
+ )
+
+
+async def _tight_boundary(_: Image.Image) -> list[PageCandidate]:
+ return [PageCandidate(bbox=(5.0, 5.0, 25.0, 25.0), metadata={"kind": "tight"})]
+
+
+def test_document_ocr_pipeline_process_image_sync() -> None:
+ pipeline = DocumentOCRPipeline(
+ _EchoOCRBackend(),
+ detection_backend=_tight_boundary,
+ )
+
+ result = pipeline.process_image_sync(
+ PageDetectionRequest(image=Image.new("RGB", (40, 30), color="white"), trim_margin=0),
+ ocr_metadata={"source": "test"},
+ )
+
+ assert result.source_type == "image"
+ assert result.texts() == ["0:20x20"]
+ assert result.pages[0].ocr_metadata["source"] == "test"
+ assert result.pages[0].metadata["kind"] == "tight"
+
+
+def test_document_ocr_pipeline_process_pdf_sync(minimal_pdf_path) -> None:
+ result = DocumentOCRPipeline(_EchoOCRBackend()).process_pdf_sync(
+ minimal_pdf_path,
+ dpi=150,
+ trim_margin=0,
+ )
+
+ assert result.source_type == "pdf"
+ assert len(result.pages) >= 1
+ assert (result.pages[0].text or "").startswith("0:")
+ assert result.metadata["path"].endswith("minimal-document.pdf")
+
+
+def test_document_ocr_pipeline_rejects_non_positive_max_concurrency() -> None:
+ with pytest.raises(ConfigurationError, match="max_concurrency"):
+ DocumentOCRPipeline(_EchoOCRBackend(), max_concurrency=0)
+
+
+@pytest.mark.asyncio
+async def test_document_ocr_pipeline_respects_max_concurrency() -> None:
+ in_flight = 0
+ max_seen = 0
+
+ class _TrackingOCRBackend(OCRBackend):
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ nonlocal in_flight, max_seen
+ in_flight += 1
+ max_seen = max(max_seen, in_flight)
+ await asyncio.sleep(0.01)
+ in_flight -= 1
+ return OCRResult(
+ text=f"page-{page.page_index}",
+ provider_name="tracking",
+ model_name="tracking-model",
+ )
+
+ async def _four_pages(image: Image.Image) -> list[PageCandidate]:
+ return [PageCandidate(image=image.copy(), metadata={"slot": index}) for index in range(4)]
+
+ pipeline = DocumentOCRPipeline(
+ _TrackingOCRBackend(),
+ detection_backend=_four_pages,
+ max_concurrency=2,
+ )
+
+ result = await pipeline.process_image(
+ PageDetectionRequest(image=Image.new("RGB", (40, 30), color="white"))
+ )
+
+ assert len(result.pages) == 4
+ assert max_seen == 2
diff --git a/tests/test_evaluation_normalization_unit.py b/tests/test_evaluation_normalization_unit.py
deleted file mode 100644
index e26dae7..0000000
--- a/tests/test_evaluation_normalization_unit.py
+++ /dev/null
@@ -1,38 +0,0 @@
-from __future__ import annotations
-
-from churro.evaluation import normalization
-
-
-def test_normalize_characters_preserves_long_s_when_requested() -> None:
- text = "ſale 1½"
- result = normalization.normalize_characters(text, keep_long_s=True)
- assert "ſ" in result
- assert "1 1⁄2" in result
-
-
-def test_normalize_characters_replaces_long_s_when_disabled() -> None:
- text = "ſafety"
- result = normalization.normalize_characters(text, keep_long_s=False)
- assert "ſ" not in result
- assert "safety" in result
-
-
-def test_normalize_text_for_evaluation_strips_markdown_and_hyphenation() -> None:
- raw = "# Heading\n> Quote line\npara-\ngraph \n[figure 2]\nword — spaced\n"
- result = normalization.normalize_text_for_evaluation(raw)
- assert "![img]" not in result
- assert "paragraph" in result
- assert "para-\n" not in result
- assert "word - spaced" in result
-
-
-def test_normalize_text_for_evaluation_with_arabic_normalization() -> None:
- raw = "السَّلَامُ"
- result = normalization.normalize_text_for_evaluation(raw, normalize_arabic=True)
- assert "َ" not in result # tashkeel removed
-
-
-def test_remove_transcription_tags() -> None:
- raw = "Adm.$^r$.Administrador dho$.dicho $ant: text $dho$"
- result = normalization.remove_transcription_tags(raw)
- assert result == "Admr dho text "
diff --git a/tests/test_hf_ocr.py b/tests/test_hf_ocr.py
new file mode 100644
index 0000000..8eabd3c
--- /dev/null
+++ b/tests/test_hf_ocr.py
@@ -0,0 +1,798 @@
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from types import ModuleType, SimpleNamespace
+from typing import Any, cast
+
+import pytest
+from PIL import Image
+
+import churro_ocr.providers.hf as hf_module
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.ocr import OCRClient
+from churro_ocr.page_detection import DocumentPage
+from churro_ocr.prompts import DEFAULT_OCR_OUTPUT_TAG
+from churro_ocr.providers import OCRBackendSpec, build_ocr_backend
+from churro_ocr.providers.hf import (
+ Churro3BOCRBackend,
+ DotsOCR15OCRBackend,
+ HuggingFaceVisionOCRBackend,
+)
+from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS
+from churro_ocr.templates import (
+ CHURRO_3B_XML_TEMPLATE,
+ DOTS_OCR_1_5_MODEL_ID,
+ DOTS_OCR_1_5_OCR_PROMPT,
+ DOTS_OCR_1_5_OCR_TEMPLATE,
+ HFChatTemplate,
+)
+
+
+def test_hf_chat_template_builds_expected_conversation() -> None:
+ template = HFChatTemplate(
+ system_message="system text",
+ user_prompt="user text",
+ )
+ page = DocumentPage.from_image(Image.new("RGB", (20, 20), color="white"))
+
+ conversation = template.build_conversation(page)
+
+ assert conversation[0]["role"] == "system"
+ assert conversation[0]["content"][0]["text"] == "system text"
+ assert conversation[1]["role"] == "user"
+ assert conversation[1]["content"][0]["type"] == "image"
+ assert conversation[1]["content"][1]["text"] == "user text"
+
+
+@pytest.mark.asyncio
+async def test_huggingface_vision_ocr_backend_uses_custom_template(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured: dict[str, Any] = {}
+ prompt_logs: list[str] = []
+
+ class FakeLogger:
+ def debug(self, message: str, *args: object) -> None:
+ prompt_logs.append(message % args if args else message)
+
+ class FakeBatch(dict[str, object]):
+ def to(self, device: object) -> FakeBatch:
+ captured["device"] = device
+ return self
+
+ class FakeProcessor:
+ def __init__(self) -> None:
+ self.tokenizer = object()
+
+ def apply_chat_template(
+ self,
+ conversation: list[dict[str, object]],
+ *,
+ add_generation_prompt: bool,
+ tokenize: bool,
+ ) -> str:
+ captured["conversation"] = conversation
+ captured["add_generation_prompt"] = add_generation_prompt
+ captured["tokenize"] = tokenize
+ return ""
+
+ def __call__(self, **kwargs: object) -> FakeBatch:
+ captured["processor_kwargs"] = kwargs
+ return FakeBatch({"input_ids": SimpleNamespace(shape=(1, 3))})
+
+ 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 ["transcription "]
+
+ 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 FakeGeneratedIds:
+ def __getitem__(self, key: object) -> object:
+ captured["generated_slice"] = key
+ return "trimmed-generated-ids"
+
+ class FakeModel:
+ device = "fake-device"
+
+ def generate(self, **kwargs: object) -> FakeGeneratedIds:
+ captured["generate_kwargs"] = kwargs
+ return FakeGeneratedIds()
+
+ 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: list[dict[str, object]],
+ *,
+ return_video_kwargs: bool,
+ return_video_metadata: bool,
+ ) -> tuple[object, None, None]:
+ captured["vision_conversation"] = conversation
+ captured["return_video_kwargs"] = return_video_kwargs
+ captured["return_video_metadata"] = return_video_metadata
+ return "fake-image-inputs", 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._internal.prompt_logging.logger", FakeLogger())
+
+ backend = HuggingFaceVisionOCRBackend(
+ model_id="stanford-oval/churro-3B",
+ template=CHURRO_3B_XML_TEMPLATE,
+ generation_kwargs={"temperature": 0.0},
+ )
+ page = await OCRClient(backend).aocr_image(
+ image=Image.new("RGBA", (5_000, 3_000), color=(255, 255, 255, 255))
+ )
+
+ assert page.text == "transcription "
+ assert page.provider_name == "huggingface-transformers"
+ assert page.model_name == "stanford-oval/churro-3B"
+ assert captured["processor_model_id"] == "stanford-oval/churro-3B"
+ assert captured["model_model_id"] == "stanford-oval/churro-3B"
+ assert captured["conversation"][0]["role"] == "system"
+ assert captured["conversation"][1]["content"][0]["type"] == "image"
+ assert captured["conversation"][1]["content"][0]["image"].size == (2_500, 1_500)
+ assert captured["conversation"][1]["content"][0]["image"].mode == "RGB"
+ assert captured["add_generation_prompt"] is True
+ assert captured["tokenize"] is False
+ assert captured["processor_kwargs"]["text"] == [""]
+ assert captured["processor_kwargs"]["images"] == ["fake-image-inputs"]
+ assert captured["generate_kwargs"] == {
+ "input_ids": SimpleNamespace(shape=(1, 3)),
+ "max_new_tokens": DEFAULT_OCR_MAX_TOKENS,
+ "temperature": 0.0,
+ }
+ assert captured["generated_slice"] == (slice(None), slice(3, None))
+ assert len(prompt_logs) == 1
+ assert "First OCR prompt payload for huggingface-transformers" in prompt_logs[0]
+ assert '"rendered_prompt": ""' in prompt_logs[0]
+ assert '"image_preview"' in prompt_logs[0]
+
+
+@pytest.mark.asyncio
+async def test_huggingface_vision_ocr_backend_requires_chat_template_support(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ class FakeProcessor:
+ tokenizer = object()
+
+ class FakeProcessorCls:
+ @staticmethod
+ def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor:
+ del model_id, kwargs
+ return FakeProcessor()
+
+ class FakeModel:
+ device = "fake-device"
+
+ class FakeModelCls:
+ @staticmethod
+ def from_pretrained(model_id: str, **kwargs: object) -> FakeModel:
+ del model_id, kwargs
+ return FakeModel()
+
+ monkeypatch.setattr(
+ "churro_ocr.providers.hf._load_hf_runtime",
+ lambda: SimpleNamespace(
+ processor_cls=FakeProcessorCls,
+ model_cls=FakeModelCls,
+ process_vision_info=lambda *_args, **_kwargs: (None, None, None),
+ ),
+ )
+
+ backend = HuggingFaceVisionOCRBackend(
+ model_id="custom-hf-model",
+ template=CHURRO_3B_XML_TEMPLATE,
+ )
+
+ with pytest.raises(ConfigurationError, match="apply_chat_template"):
+ await OCRClient(backend).aocr_image(
+ image=Image.new("RGBA", (5_000, 3_000), color=(255, 255, 255, 255))
+ )
+
+
+def test_churro_3b_backend_uses_expected_defaults() -> None:
+ backend = Churro3BOCRBackend()
+
+ assert backend.model_id == "stanford-oval/churro-3B"
+ assert backend.template == CHURRO_3B_XML_TEMPLATE
+ assert backend.provider_name == "huggingface-transformers"
+
+
+@pytest.mark.asyncio
+async def test_dots_ocr_15_backend_uses_expected_runtime_and_prompt(
+ 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:
+ tokenizer = object()
+
+ def apply_chat_template(
+ self,
+ conversation: list[dict[str, object]],
+ *,
+ add_generation_prompt: bool,
+ tokenize: bool,
+ ) -> str:
+ captured["conversation"] = conversation
+ captured["add_generation_prompt"] = add_generation_prompt
+ captured["tokenize"] = tokenize
+ return ""
+
+ def __call__(self, **kwargs: object) -> FakeBatch:
+ captured["processor_kwargs"] = kwargs
+ return FakeBatch({"input_ids": SimpleNamespace(shape=(1, 4))})
+
+ def batch_decode(
+ self,
+ generated_ids: object,
+ *,
+ skip_special_tokens: bool,
+ clean_up_tokenization_spaces: bool,
+ ) -> list[str]:
+ captured["generated_ids"] = generated_ids
+ return ["dots transcription"]
+
+ 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 FakeGeneratedIds:
+ def __getitem__(self, key: object) -> object:
+ captured["generated_slice"] = key
+ return "trimmed-dots-generated-ids"
+
+ class FakeModel:
+ device = "fake-device"
+
+ def generate(self, **kwargs: object) -> FakeGeneratedIds:
+ captured["generate_kwargs"] = kwargs
+ return FakeGeneratedIds()
+
+ 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: list[dict[str, object]],
+ *,
+ return_video_kwargs: bool,
+ return_video_metadata: bool,
+ ) -> tuple[object, None, None]:
+ captured["vision_conversation"] = conversation
+ captured["return_video_kwargs"] = return_video_kwargs
+ captured["return_video_metadata"] = return_video_metadata
+ return "fake-image-inputs", None, None
+
+ monkeypatch.setattr(
+ "churro_ocr.providers.hf._load_hf_causal_runtime",
+ lambda: SimpleNamespace(
+ processor_cls=FakeProcessorCls,
+ model_cls=FakeModelCls,
+ process_vision_info=fake_process_vision_info,
+ ),
+ )
+ monkeypatch.setattr(
+ "churro_ocr.providers.hf._prepare_dots_ocr_model_dir",
+ lambda model_id: model_id,
+ )
+
+ backend = DotsOCR15OCRBackend()
+ page = await OCRClient(backend).aocr_image(image=Image.new("RGB", (32, 32), color="white"))
+
+ assert page.text == "dots transcription"
+ assert page.provider_name == "huggingface-transformers"
+ assert page.model_name == "dots.ocr-1.5"
+ assert captured["processor_model_id"] == DOTS_OCR_1_5_MODEL_ID
+ assert captured["model_model_id"] == DOTS_OCR_1_5_MODEL_ID
+ assert captured["processor_from_pretrained_kwargs"]["trust_remote_code"] is True
+ assert "use_fast" not in captured["processor_from_pretrained_kwargs"]
+ assert captured["model_from_pretrained_kwargs"]["trust_remote_code"] is True
+ assert captured["conversation"][0]["role"] == "user"
+ assert captured["conversation"][0]["content"][1]["text"] == DOTS_OCR_1_5_OCR_PROMPT
+ assert captured["processor_kwargs"]["text"] == [""]
+ assert captured["processor_kwargs"]["images"] == ["fake-image-inputs"]
+ assert "videos" not in captured["processor_kwargs"]
+ assert captured["generate_kwargs"] == {
+ "input_ids": SimpleNamespace(shape=(1, 4)),
+ "max_new_tokens": DEFAULT_OCR_MAX_TOKENS,
+ }
+ assert captured["generated_slice"] == (slice(None), slice(4, None))
+
+
+def test_dots_ocr_15_backend_uses_expected_defaults() -> None:
+ backend = DotsOCR15OCRBackend()
+
+ assert backend.model_id == DOTS_OCR_1_5_MODEL_ID
+ assert backend.template == DOTS_OCR_1_5_OCR_TEMPLATE
+ assert backend.trust_remote_code is True
+ assert backend.processor_kwargs == {}
+ assert backend.model_kwargs["dtype"] in {"auto", "float32"}
+ if backend.model_kwargs["dtype"] == "auto":
+ assert backend.model_kwargs["device_map"] == "auto"
+ assert "max_memory" in backend.model_kwargs
+ assert backend.generation_kwargs == {"max_new_tokens": DEFAULT_OCR_MAX_TOKENS}
+
+
+@pytest.mark.asyncio
+async def test_huggingface_vision_ocr_backend_strips_default_output_tags(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ class FakeBatch(dict[str, object]):
+ def to(self, device: object) -> FakeBatch:
+ del device
+ return self
+
+ class FakeProcessor:
+ tokenizer = object()
+
+ def apply_chat_template(
+ self,
+ conversation: list[dict[str, object]],
+ *,
+ add_generation_prompt: bool,
+ tokenize: bool,
+ ) -> str:
+ del conversation, add_generation_prompt, tokenize
+ return ""
+
+ def __call__(self, **kwargs: object) -> FakeBatch:
+ del kwargs
+ return FakeBatch({"input_ids": SimpleNamespace(shape=(1, 3))})
+
+ def batch_decode(
+ self,
+ generated_ids: object,
+ *,
+ skip_special_tokens: bool,
+ clean_up_tokenization_spaces: bool,
+ ) -> list[str]:
+ del generated_ids, skip_special_tokens, clean_up_tokenization_spaces
+ return [f"<{DEFAULT_OCR_OUTPUT_TAG}>\ntranscription\n{DEFAULT_OCR_OUTPUT_TAG}>"]
+
+ class FakeProcessorCls:
+ @staticmethod
+ def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor:
+ del model_id, kwargs
+ return FakeProcessor()
+
+ class FakeGeneratedIds:
+ def __getitem__(self, key: object) -> object:
+ del key
+ return "trimmed-generated-ids"
+
+ class FakeModel:
+ device = "fake-device"
+
+ def generate(self, **kwargs: object) -> FakeGeneratedIds:
+ del kwargs
+ return FakeGeneratedIds()
+
+ class FakeModelCls:
+ @staticmethod
+ def from_pretrained(model_id: str, **kwargs: object) -> FakeModel:
+ del model_id, kwargs
+ return FakeModel()
+
+ def fake_process_vision_info(
+ conversation: list[dict[str, object]],
+ *,
+ return_video_kwargs: bool,
+ return_video_metadata: bool,
+ ) -> tuple[object, None, None]:
+ del conversation, return_video_kwargs, return_video_metadata
+ return "fake-image-inputs", 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,
+ ),
+ )
+
+ backend = build_ocr_backend(OCRBackendSpec(provider="hf", model="example/model"))
+ page = await OCRClient(backend).aocr_image(image=Image.new("RGB", (32, 32), color="white"))
+
+ assert page.text == "transcription"
+
+
+@pytest.mark.parametrize(
+ ("loader_name", "expected_model_attr"),
+ [
+ ("_load_hf_runtime", "AutoModelForImageTextToText"),
+ ("_load_hf_causal_runtime", "AutoModelForCausalLM"),
+ ],
+)
+def test_hf_runtime_loaders_use_installed_modules(
+ monkeypatch: pytest.MonkeyPatch,
+ loader_name: str,
+ expected_model_attr: str,
+) -> None:
+ process_vision_info = object()
+ qwen_module = ModuleType("qwen_vl_utils")
+ 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
+
+ monkeypatch.setitem(sys.modules, "qwen_vl_utils", qwen_module)
+ monkeypatch.setitem(sys.modules, "transformers", transformers_module)
+
+ runtime = getattr(hf_module, loader_name)()
+
+ assert runtime.processor_cls is processor_cls
+ assert runtime.model_cls is getattr(transformers_module, expected_model_attr)
+ assert runtime.process_vision_info is process_vision_info
+
+
+def test_patch_dots_ocr_vision_module_rewrites_flash_attn_and_dtype_lines(tmp_path: Path) -> None:
+ model_dir = tmp_path / "model"
+ model_dir.mkdir()
+ vision_module_path = model_dir / "modeling_dots_vision.py"
+ vision_module_path.write_text(
+ "\n".join(
+ [
+ "from flash_attn import flash_attn_varlen_func",
+ "",
+ "def forward(self, hidden_states):",
+ hf_module._DOTS_FORCE_BFLOAT16_LINE,
+ " return hidden_states",
+ ]
+ )
+ + "\n"
+ )
+
+ hf_module._patch_dots_ocr_vision_module(model_dir)
+ patched_once = vision_module_path.read_text()
+ hf_module._patch_dots_ocr_vision_module(model_dir)
+
+ assert hf_module._DOTS_FLASH_ATTN_FALLBACK.strip() in patched_once
+ assert patched_once.startswith("try:\n")
+ assert hf_module._DOTS_FORCE_BFLOAT16_LINE not in patched_once
+ assert hf_module._DOTS_WEIGHT_DTYPE_LINE in patched_once
+ assert vision_module_path.read_text() == patched_once
+
+
+def test_prepare_dots_ocr_model_dir_downloads_and_patches(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: Path,
+) -> None:
+ download_calls: list[tuple[str, Path]] = []
+ 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)
+ )
+ monkeypatch.setitem(sys.modules, "huggingface_hub", huggingface_hub_module)
+ monkeypatch.setattr(hf_module.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(
+ hf_module,
+ "_patch_dots_ocr_vision_module",
+ lambda model_dir: patched_paths.append(model_dir),
+ )
+
+ prepared_path = hf_module._prepare_dots_ocr_model_dir("org/model.id")
+
+ expected_dir = tmp_path / ".cache" / "churro-ocr" / "hf" / "DotsOCR_1_5" / "org__model_id"
+ assert prepared_path == str(expected_dir)
+ assert download_calls == [("org/model.id", expected_dir)]
+ assert patched_paths == [expected_dir]
+
+
+@pytest.mark.parametrize(
+ ("cuda_available", "free_bytes", "expected"),
+ [
+ (False, 0, {"dtype": "auto"}),
+ (True, 7 * 1024**3, {"dtype": "float32"}),
+ (
+ True,
+ 16 * 1024**3,
+ {"dtype": "auto", "device_map": "auto", "max_memory": {0: "15GiB", "cpu": "128GiB"}},
+ ),
+ ],
+)
+def test_default_dots_ocr_1_5_model_kwargs_handles_cuda_variants(
+ monkeypatch: pytest.MonkeyPatch,
+ cuda_available: bool,
+ free_bytes: int,
+ expected: dict[str, object],
+) -> None:
+ class _FakeCuda:
+ @staticmethod
+ def is_available() -> bool:
+ return cuda_available
+
+ @staticmethod
+ def mem_get_info() -> tuple[int, int]:
+ return free_bytes, 0
+
+ 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() == expected
+
+
+@pytest.mark.asyncio
+async def test_huggingface_vision_ocr_backend_batches_pages_with_custom_vision_inputs(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ captured: dict[str, object] = {}
+ prompt_logs: list[str] = []
+ chat_calls: list[tuple[bool, bool]] = []
+
+ class FakeLogger:
+ def debug(self, message: str, *args: object) -> None:
+ prompt_logs.append(message % args if args else message)
+
+ class FakeFloatDType:
+ is_floating_point = True
+
+ class FakeFloatTensor:
+ def __init__(self) -> None:
+ self.dtype = FakeFloatDType()
+ self.to_calls: list[object] = []
+
+ def to(self, *, dtype: object) -> FakeFloatTensor:
+ self.to_calls.append(dtype)
+ return self
+
+ class FakeAttentionMask:
+ def sum(self, *, dim: int) -> SimpleNamespace:
+ captured["sum_dim"] = dim
+ return SimpleNamespace(tolist=lambda: [2, 1])
+
+ class FakeBatch(dict[str, object]):
+ def to(self, device: object) -> FakeBatch:
+ captured["device"] = device
+ return self
+
+ class FakeProcessor:
+ tokenizer = object()
+
+ def apply_chat_template(
+ self,
+ conversation: list[dict[str, object]],
+ *,
+ add_generation_prompt: bool,
+ 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"])
+ return f"prompt:{image.width}"
+
+ def __call__(self, **kwargs: object) -> FakeBatch:
+ captured["processor_kwargs"] = kwargs
+ fake_pixel_values = FakeFloatTensor()
+ captured["pixel_values"] = fake_pixel_values
+ return FakeBatch(
+ {
+ "attention_mask": FakeAttentionMask(),
+ "pixel_values": fake_pixel_values,
+ }
+ )
+
+ def batch_decode(
+ self,
+ generated_ids: object,
+ *,
+ skip_special_tokens: bool,
+ clean_up_tokenization_spaces: bool,
+ ) -> list[str]:
+ captured["generated_ids"] = generated_ids
+ captured["decode_kwargs"] = (skip_special_tokens, clean_up_tokenization_spaces)
+ return ["first text", "second text"]
+
+ class FakeProcessorCls:
+ call_count = 0
+
+ @staticmethod
+ def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor:
+ FakeProcessorCls.call_count += 1
+ captured["processor_model_id"] = model_id
+ captured["processor_from_pretrained_kwargs"] = kwargs
+ return FakeProcessor()
+
+ class FakeModel:
+ device = "cuda:0"
+ dtype = "float16"
+
+ def generate(self, **kwargs: object) -> list[list[int]]:
+ captured["generate_kwargs"] = kwargs
+ return [
+ [100, 101, 102, 103],
+ [200, 201, 202],
+ ]
+
+ class FakeModelCls:
+ call_count = 0
+
+ @staticmethod
+ def from_pretrained(model_id: str, **kwargs: object) -> FakeModel:
+ FakeModelCls.call_count += 1
+ captured["model_model_id"] = model_id
+ captured["model_from_pretrained_kwargs"] = kwargs
+ return FakeModel()
+
+ monkeypatch.setattr(
+ hf_module,
+ "_load_hf_runtime",
+ lambda: SimpleNamespace(
+ processor_cls=FakeProcessorCls,
+ model_cls=FakeModelCls,
+ process_vision_info=lambda *args, **kwargs: (_ for _ in ()).throw(
+ AssertionError(f"unexpected process_vision_info call: {args!r}, {kwargs!r}")
+ ),
+ ),
+ )
+ 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"])
+ return f"image:{image.width}", f"video:{image.width}"
+
+ backend = HuggingFaceVisionOCRBackend(
+ model_id="example/model",
+ template=HFChatTemplate(user_prompt="prompt"),
+ processor_kwargs={"use_fast": False},
+ model_kwargs={"device_map": "auto"},
+ generation_kwargs={"temperature": 0.1},
+ vision_input_builder=_vision_input_builder,
+ )
+ pages = [
+ DocumentPage.from_image(Image.new("RGB", (10, 10), color="white")),
+ DocumentPage.from_image(Image.new("RGB", (20, 20), color="white")),
+ ]
+
+ results = await backend.ocr_batch(pages)
+
+ assert [result.text for result in results] == ["first text", "second text"]
+ assert captured["processor_model_id"] == "example/model"
+ assert captured["processor_from_pretrained_kwargs"] == {
+ "trust_remote_code": False,
+ "use_fast": False,
+ }
+ assert captured["model_model_id"] == "example/model"
+ assert captured["model_from_pretrained_kwargs"] == {
+ "trust_remote_code": False,
+ "device_map": "auto",
+ }
+ assert captured["processor_kwargs"] == {
+ "text": ["prompt:10", "prompt:20"],
+ "images": [["image:10"], ["image:20"]],
+ "videos": [["video:10"], ["video:20"]],
+ "return_tensors": "pt",
+ "padding": True,
+ }
+ assert captured["device"] == "cuda:0"
+ 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"])
+ assert generate_kwargs["temperature"] == 0.1
+ assert generate_kwargs["max_new_tokens"] == DEFAULT_OCR_MAX_TOKENS
+ assert generate_kwargs["attention_mask"].__class__.__name__ == ("FakeAttentionMask")
+ assert generate_kwargs["pixel_values"] is fake_pixel_values
+ assert captured["generated_ids"] == [[102, 103], [201, 202]]
+ assert captured["decode_kwargs"] == (True, False)
+ assert FakeProcessorCls.call_count == 1
+ assert FakeModelCls.call_count == 1
+ assert chat_calls == [(True, False), (True, False)]
+ assert len(prompt_logs) == 1
+ assert "First OCR prompt payload for huggingface-transformers" in prompt_logs[0]
+
+
+def test_huggingface_vision_ocr_backend_batch_returns_empty_list_for_no_pages() -> None:
+ backend = HuggingFaceVisionOCRBackend(
+ model_id="example/model",
+ template=HFChatTemplate(user_prompt="prompt"),
+ )
+
+ assert backend._ocr_batch_sync([]) == []
+
+
+@pytest.mark.parametrize("vision_config", [{}, SimpleNamespace()])
+def test_dots_ocr_15_backend_get_model_sets_sdpa_on_vision_config(
+ monkeypatch: pytest.MonkeyPatch,
+ vision_config: dict[str, str] | SimpleNamespace,
+) -> None:
+ captured: dict[str, object] = {}
+ config = SimpleNamespace(vision_config=vision_config)
+
+ class FakeAutoConfig:
+ @staticmethod
+ def from_pretrained(model_source: str, **kwargs: object) -> object:
+ captured["config_model_source"] = model_source
+ captured["config_from_pretrained_kwargs"] = kwargs
+ return config
+
+ class FakeModelCls:
+ call_count = 0
+
+ @staticmethod
+ def from_pretrained(model_source: str, **kwargs: object) -> object:
+ FakeModelCls.call_count += 1
+ captured["model_model_source"] = model_source
+ captured["model_from_pretrained_kwargs"] = kwargs
+ return object()
+
+ transformers_module = ModuleType("transformers")
+ cast(Any, transformers_module).AutoConfig = FakeAutoConfig
+ monkeypatch.setitem(sys.modules, "transformers", transformers_module)
+ monkeypatch.setattr(
+ hf_module,
+ "_prepare_dots_ocr_model_dir",
+ lambda model_id: f"/prepared/{model_id.replace('/', '__')}",
+ )
+
+ backend = DotsOCR15OCRBackend(model_kwargs={"torch_dtype": "auto"})
+ runtime = hf_module._HFRuntime(
+ processor_cls=object(),
+ model_cls=FakeModelCls,
+ process_vision_info=object(),
+ )
+
+ first_model = backend._get_model(runtime)
+ second_model = backend._get_model(runtime)
+
+ assert first_model is second_model
+ assert captured["config_model_source"] == "/prepared/kristaller486__dots.ocr-1.5"
+ assert captured["config_from_pretrained_kwargs"] == {"trust_remote_code": True}
+ assert captured["model_model_source"] == "/prepared/kristaller486__dots.ocr-1.5"
+ assert captured["model_from_pretrained_kwargs"] == {
+ "config": config,
+ "trust_remote_code": True,
+ "torch_dtype": "auto",
+ }
+ assert FakeModelCls.call_count == 1
+ if isinstance(vision_config, dict):
+ assert vision_config["attn_implementation"] == "sdpa"
+ else:
+ assert vision_config.attn_implementation == "sdpa"
diff --git a/tests/test_hf_ocr_integration.py b/tests/test_hf_ocr_integration.py
new file mode 100644
index 0000000..865ec12
--- /dev/null
+++ b/tests/test_hf_ocr_integration.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+import os
+
+import pytest
+
+from churro_ocr.document import DocumentOCRPipeline
+from churro_ocr.providers import HuggingFaceOptions, OCRBackendSpec, build_ocr_backend
+
+_LIVE_FLAG = "CHURRO_RUN_LIVE_HF_TESTS"
+_ALLOW_CPU_FLAG = "CHURRO_ALLOW_CPU_HF_TESTS"
+_MODEL_ENV = "CHURRO_HF_MODEL_ID"
+_DEVICE_MAP_ENV = "CHURRO_HF_DEVICE_MAP"
+_DEFAULT_MODEL_ID = "stanford-oval/churro-3B"
+
+
+@pytest.mark.integration
+def test_churro_3b_live_hf_ocr_on_minimal_pdf(minimal_pdf_path, test_artifact_dir_path) -> None:
+ if os.getenv(_LIVE_FLAG) != "1":
+ pytest.skip(f"Set {_LIVE_FLAG}=1 to run live Hugging Face OCR integration tests.")
+
+ torch = pytest.importorskip("torch")
+ if not torch.cuda.is_available() and os.getenv(_ALLOW_CPU_FLAG) != "1":
+ pytest.skip(
+ "CUDA is unavailable. Set CHURRO_ALLOW_CPU_HF_TESTS=1 "
+ "to allow the HF OCR integration test on CPU."
+ )
+
+ model_id = os.getenv(_MODEL_ENV, _DEFAULT_MODEL_ID)
+ device_map = os.getenv(_DEVICE_MAP_ENV, "auto")
+
+ backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="hf",
+ model=model_id,
+ profile=_DEFAULT_MODEL_ID,
+ options=HuggingFaceOptions(
+ model_kwargs={"device_map": device_map},
+ ),
+ )
+ )
+ pipeline = DocumentOCRPipeline(backend)
+
+ result = pipeline.process_pdf_sync(minimal_pdf_path, dpi=150, trim_margin=0)
+
+ test_artifact_dir_path.mkdir(parents=True, exist_ok=True)
+ output_path = test_artifact_dir_path / "hf-churro3b-minimal-document.xml"
+ output_path.write_text("\n\n".join(result.texts()))
+
+ assert result.source_type == "pdf"
+ assert len(result.pages) >= 1
+ assert result.pages[0].provider_name == "huggingface-transformers"
+ assert result.pages[0].model_name == "churro-3B"
+ assert result.pages[0].text is not None
+ assert result.pages[0].text.strip()
diff --git a/tests/test_internal_helpers.py b/tests/test_internal_helpers.py
new file mode 100644
index 0000000..7f33cfe
--- /dev/null
+++ b/tests/test_internal_helpers.py
@@ -0,0 +1,370 @@
+from __future__ import annotations
+
+import sys
+from base64 import b64encode
+from threading import Lock
+from types import ModuleType, SimpleNamespace
+from typing import Any, cast
+
+import pytest
+from PIL import Image
+
+import churro_ocr._internal.litellm as litellm_module
+import churro_ocr._internal.prompt_logging as prompt_logging_module
+from churro_ocr._internal import logging as logging_module
+from churro_ocr._internal.image import image_to_base64, load_image
+from churro_ocr._internal.litellm import LiteLLMTransport, complete_text
+from churro_ocr.errors import ConfigurationError, ProviderError
+from churro_ocr.providers.hf import _load_hf_causal_runtime, _load_hf_runtime
+from churro_ocr.providers.specs import LiteLLMTransportConfig
+from churro_ocr.providers.vllm import _load_vllm_processor_cls, _load_vllm_runtime
+
+
+def _make_fake_litellm_module(*, acompletion: object, completion_cost: object | None = None) -> ModuleType:
+ module = cast(Any, ModuleType("litellm"))
+ module.acompletion = acompletion
+ module.completion_cost = completion_cost or (lambda **_: None)
+ module.turn_off_message_logging = False
+ module.success_callback = ["stale"]
+ module.failure_callback = ["stale"]
+ module._logging = SimpleNamespace(_logged_requests=["stale"]) # noqa: SLF001
+ module.drop_params = False
+ module.suppress_debug_info = False
+ module.set_verbose = True
+ module.cache = None
+ module.input_callback = []
+ return module
+
+
+def test_load_image_rejects_missing_path(tmp_path) -> None:
+ missing = tmp_path / "missing.png"
+
+ with pytest.raises(ConfigurationError, match="Image path does not exist"):
+ load_image(missing)
+
+
+def test_image_to_base64_falls_back_to_png_for_unknown_formats() -> None:
+ mime_type, encoded = image_to_base64(Image.new("RGB", (4, 4), color="white"), format_name="TIFF")
+
+ assert mime_type == "image/png"
+ assert encoded
+
+
+def test_prepare_messages_includes_system_prompt_user_prompt_and_image_detail() -> None:
+ messages = litellm_module.prepare_messages(
+ system_prompt="system text",
+ user_prompt="user text",
+ images=[Image.new("RGB", (4, 4), color="white")],
+ image_detail="low",
+ )
+
+ assert messages[0] == {
+ "role": "system",
+ "content": [{"type": "text", "text": "system text"}],
+ }
+ assert messages[1]["role"] == "user"
+ assert messages[1]["content"][0]["type"] == "image_url"
+ assert messages[1]["content"][0]["image_url"]["detail"] == "low"
+ assert messages[1]["content"][1] == {"type": "text", "text": "user text"}
+
+
+def test_prepare_messages_from_conversation_converts_images_and_preserves_unknown_items() -> None:
+ messages = litellm_module.prepare_messages_from_conversation(
+ [
+ {
+ "role": "user",
+ "content": [
+ {"type": "image", "image": Image.new("RGB", (3, 3), color="white")},
+ {"type": "text", "text": "prompt"},
+ {"type": "audio", "audio": "raw"},
+ ],
+ }
+ ],
+ image_detail="high",
+ )
+
+ image_item = messages[0]["content"][0]
+ assert image_item["type"] == "image_url"
+ assert image_item["image_url"]["detail"] == "high"
+ assert image_item["image_url"]["url"].startswith("data:image/png;base64,")
+ assert messages[0]["content"][1:] == [
+ {"type": "text", "text": "prompt"},
+ {"type": "audio", "audio": "raw"},
+ ]
+
+
+@pytest.mark.asyncio
+async def test_complete_text_wrapper_passes_transport_config(monkeypatch: pytest.MonkeyPatch) -> None:
+ captured: dict[str, object] = {}
+
+ async def _fake_complete_text(
+ self: LiteLLMTransport,
+ *,
+ model: str,
+ messages: list[dict[str, object]],
+ timeout_seconds: int = 600,
+ output_json: bool = False,
+ ) -> str:
+ captured["config"] = self.config
+ captured["model"] = model
+ captured["messages"] = messages
+ captured["timeout_seconds"] = timeout_seconds
+ captured["output_json"] = output_json
+ return "ok"
+
+ monkeypatch.setattr(
+ "churro_ocr._internal.litellm.LiteLLMTransport.complete_text",
+ _fake_complete_text,
+ )
+
+ result = await complete_text(
+ model="example/model",
+ messages=[{"role": "user", "content": [{"type": "text", "text": "hello"}]}],
+ api_base="https://example.invalid/v1",
+ api_key="secret",
+ api_version="2025-01-01",
+ timeout_seconds=42,
+ output_json=True,
+ completion_kwargs={"temperature": 0},
+ )
+
+ config = captured["config"]
+ assert isinstance(config, LiteLLMTransportConfig)
+ assert result == "ok"
+ assert config.api_base == "https://example.invalid/v1"
+ assert config.api_key == "secret"
+ assert config.api_version == "2025-01-01"
+ assert config.completion_kwargs == {"temperature": 0}
+ assert captured["timeout_seconds"] == 42
+ assert captured["output_json"] is True
+
+
+def test_extract_response_cost_uses_completion_cost_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
+ fake_module = _make_fake_litellm_module(
+ acompletion=lambda **_: None,
+ completion_cost=lambda **_: 0.75,
+ )
+ monkeypatch.setitem(sys.modules, "litellm", fake_module)
+
+ cost = litellm_module._extract_response_cost(model="example/model", response=SimpleNamespace())
+
+ assert cost == pytest.approx(0.75)
+
+
+def test_extract_response_cost_returns_none_for_non_numeric_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
+ fake_module = _make_fake_litellm_module(
+ acompletion=lambda **_: None,
+ completion_cost=lambda **_: "free",
+ )
+ monkeypatch.setitem(sys.modules, "litellm", fake_module)
+
+ cost = litellm_module._extract_response_cost(model="example/model", response=SimpleNamespace())
+
+ assert cost is None
+
+
+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] = []
+ worker = SimpleNamespace()
+ worker.ensure_initialized_and_enqueue = lambda async_coroutine: worker_calls.append(async_coroutine)
+
+ monkeypatch.setitem(sys.modules, "litellm", fake_module)
+ monkeypatch.setattr(litellm_module, "_INITIALIZED", False)
+ monkeypatch.setattr(
+ litellm_module,
+ "import_module",
+ lambda name: (
+ SimpleNamespace(GLOBAL_LOGGING_WORKER=worker)
+ if name == "litellm.litellm_core_utils.logging_worker"
+ else __import__(name)
+ ),
+ )
+
+ litellm_module._ensure_initialized()
+ closable = SimpleNamespace(close=lambda: worker_calls.append("closed"))
+ worker.ensure_initialized_and_enqueue(closable)
+
+ assert worker_calls == ["closed"]
+ assert fake_module.turn_off_message_logging is True
+ assert fake_module.success_callback == []
+ assert fake_module.failure_callback == []
+
+
+def test_configure_disk_cache_enables_and_updates_cache(monkeypatch: pytest.MonkeyPatch, tmp_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.enable_cache = lambda **kwargs: enable_calls.append(kwargs)
+ caching_module.update_cache = lambda **kwargs: update_calls.append(kwargs)
+
+ monkeypatch.setitem(sys.modules, "litellm", fake_module)
+ monkeypatch.setitem(sys.modules, "litellm.caching", ModuleType("litellm.caching"))
+ monkeypatch.setitem(sys.modules, "litellm.caching.caching", caching_module)
+ monkeypatch.setattr(litellm_module, "_INITIALIZED", False)
+ monkeypatch.setattr(litellm_module, "_DISK_CACHE_DIR", None)
+
+ first_cache_dir = tmp_path / "first"
+ 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.cache = object()
+ fake_module.input_callback = ["cache"]
+ litellm_module.configure_disk_cache(disk_cache_dir=second_cache_dir)
+
+ assert enable_calls == [{"type": "disk", "disk_cache_dir": str(first_cache_dir.resolve())}]
+ assert update_calls == [{"type": "disk", "disk_cache_dir": str(second_cache_dir.resolve())}]
+
+
+@pytest.mark.asyncio
+async def test_transport_complete_text_raises_provider_error_on_failure(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _failing_acompletion(**_: object) -> object:
+ raise RuntimeError("boom")
+
+ fake_module = _make_fake_litellm_module(acompletion=_failing_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': boom"):
+ await transport.complete_text(
+ model="example/model",
+ messages=[{"role": "user", "content": [{"type": "text", "text": "hello"}]}],
+ )
+
+
+@pytest.mark.asyncio
+async def test_transport_complete_text_rejects_empty_output(monkeypatch: pytest.MonkeyPatch) -> None:
+ async def _empty_acompletion(**_: object) -> object:
+ return SimpleNamespace(
+ choices=[SimpleNamespace(message=SimpleNamespace(content=" "))],
+ _hidden_params={},
+ )
+
+ fake_module = _make_fake_litellm_module(acompletion=_empty_acompletion)
+ monkeypatch.setitem(sys.modules, "litellm", fake_module)
+ monkeypatch.setattr(litellm_module, "_INITIALIZED", False)
+
+ transport = LiteLLMTransport()
+ with pytest.raises(ProviderError, match="LiteLLM returned empty output for model 'example/model'"):
+ await transport.complete_text(
+ model="example/model",
+ messages=[{"role": "user", "content": [{"type": "text", "text": "hello"}]}],
+ )
+
+
+def test_logger_adapter_supports_success_fallback_and_other_levels() -> None:
+ records: list[tuple[str, str]] = []
+
+ class _FakeLogger:
+ def success(self, message: str) -> None:
+ records.append(("success", message))
+
+ def info(self, message: str) -> None:
+ records.append(("info", message))
+
+ def warning(self, message: str) -> None:
+ records.append(("warning", message))
+
+ def critical(self, message: str) -> None:
+ records.append(("critical", message))
+
+ def exception(self, message: str) -> None:
+ records.append(("exception", message))
+
+ def debug(self, message: str) -> None:
+ records.append(("debug", message))
+
+ def log(self, level: str, message: str) -> None:
+ records.append((level, message))
+
+ logger = logging_module._LoggerAdapter(_FakeLogger())
+ logger.success("Saved %s", "result")
+ logger.warning("Warn %s", "user")
+ logger.critical("Critical %s", "path")
+ logger.exception("Exception %s", "case")
+ logger.debug("Debug %s", "value")
+ logger.log("NOTICE", "Notice %s", 1)
+
+ assert records == [
+ ("success", "Saved result"),
+ ("warning", "Warn user"),
+ ("critical", "Critical path"),
+ ("exception", "Exception case"),
+ ("debug", "Debug value"),
+ ("NOTICE", "Notice 1"),
+ ]
+
+
+def test_logger_adapter_success_falls_back_to_info_when_success_missing() -> None:
+ records: list[tuple[str, str]] = []
+
+ class _FakeLogger:
+ def info(self, message: str) -> None:
+ records.append(("info", message))
+
+ logger = logging_module._LoggerAdapter(_FakeLogger())
+ logger.success("Fallback %s", "message")
+
+ assert records == [("info", "Fallback message")]
+
+
+def test_log_prompt_payload_once_sanitizes_nested_payloads(monkeypatch: pytest.MonkeyPatch) -> None:
+ messages: list[str] = []
+ state = {"logged": False}
+
+ class _FakeLogger:
+ def debug(self, message: str, *args: object) -> None:
+ messages.append(message % args if args else message)
+
+ monkeypatch.setattr("churro_ocr._internal.prompt_logging.logger", _FakeLogger())
+
+ prompt_logging_module.log_prompt_payload_once(
+ payload={
+ "image": Image.new("RGB", (4, 4), color="white"),
+ "bytes": b"payload",
+ "tuple": ("keep", f"data:image/png;base64,{b64encode(b'payload').decode('ascii')}"),
+ },
+ provider_name="test-provider",
+ has_logged=lambda: state["logged"],
+ lock=Lock(),
+ set_logged=lambda: state.__setitem__("logged", True),
+ )
+ prompt_logging_module.log_prompt_payload_once(
+ payload="ignored",
+ provider_name="test-provider",
+ has_logged=lambda: state["logged"],
+ lock=Lock(),
+ set_logged=lambda: state.__setitem__("logged", True),
+ )
+
+ assert len(messages) == 1
+ assert "First OCR prompt payload for test-provider" in messages[0]
+ assert '"type": "image"' in messages[0]
+ assert '"type": "bytes"' in messages[0]
+ assert "data:image/png;base64," in messages[0]
+
+
+@pytest.mark.parametrize(
+ ("loader", "dependency_name"),
+ [
+ (_load_vllm_processor_cls, "transformers"),
+ (_load_vllm_runtime, "vllm"),
+ (_load_hf_runtime, "qwen_vl_utils"),
+ (_load_hf_causal_runtime, "qwen_vl_utils"),
+ ],
+)
+def test_optional_dependency_loaders_raise_configuration_error(
+ loader: Any,
+ dependency_name: str,
+ patch_import_failure,
+) -> None:
+ patch_import_failure(failing_name=dependency_name)
+
+ with pytest.raises(ConfigurationError):
+ loader()
diff --git a/tests/test_layout_api.py b/tests/test_layout_api.py
new file mode 100644
index 0000000..3c7db97
--- /dev/null
+++ b/tests/test_layout_api.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+import pytest
+from PIL import Image
+
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.page_detection import (
+ DocumentPageDetector,
+ PageCandidate,
+ PageDetectionRequest,
+ PageDetector,
+)
+
+
+async def _two_pages(_: Image.Image) -> list[PageCandidate]:
+ return [
+ PageCandidate(bbox=(5.0, 5.0, 25.0, 25.0), metadata={"kind": "left"}),
+ PageCandidate(bbox=(30.0, 5.0, 55.0, 25.0), metadata={"kind": "right"}),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_document_page_detector_detects_multiple_pages() -> None:
+ page_detector = DocumentPageDetector(backend=_two_pages)
+
+ result = await page_detector.detect_image(
+ PageDetectionRequest(image=Image.new("RGB", (60, 30), color="white"), trim_margin=0)
+ )
+
+ assert len(result.pages) == 2
+ assert result.pages[0].image.size == (20, 20)
+ assert result.pages[1].metadata["kind"] == "right"
+
+
+def test_page_detector_returns_page_list() -> None:
+ pages = PageDetector(_two_pages).detect(
+ PageDetectionRequest(image=Image.new("RGB", (60, 30), color="white"), trim_margin=0)
+ )
+
+ assert len(pages) == 2
+ assert pages[0].page_index == 0
+ assert pages[1].page_index == 1
+
+
+def test_document_page_detector_detect_pdf_sync_uses_real_pdf(minimal_pdf_path) -> None:
+ result = DocumentPageDetector().detect_pdf_sync(minimal_pdf_path, dpi=150, trim_margin=0)
+
+ assert result.source_type == "pdf"
+ assert len(result.pages) >= 1
+ assert result.pages[0].image.width > 0
+
+
+def test_page_detection_request_requires_exactly_one_image_input(write_image_file) -> None:
+ image_path = write_image_file(size=(12, 12))
+ with pytest.raises(ConfigurationError, match="exactly one"):
+ PageDetectionRequest().require_image()
+
+ with pytest.raises(ConfigurationError, match="exactly one"):
+ PageDetectionRequest(
+ image=Image.new("RGB", (12, 12), color="white"),
+ image_path=image_path,
+ ).require_image()
diff --git a/tests/test_llm_async.py b/tests/test_llm_async.py
deleted file mode 100644
index dda22f4..0000000
--- a/tests/test_llm_async.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""Async smoke tests: ensure each model returns a non-empty string."""
-
-import pytest
-
-from churro.utils.llm import run_llm_async
-
-
-# Sonnet 3.7 is a hybrid model
-@pytest.mark.asyncio
-@pytest.mark.parametrize(
- "model_key",
- [
- # non-reasoning models
- "gpt-4.1",
- "gpt-4.1-mini",
- "gpt-4o",
- "gpt-4o-mini",
- "sonnet-3.7",
- "gemini-2.5-flash-noreasoning",
- # Reasoning models on low setting
- "gemini-2.5-flash-low",
- "gemini-2.5-pro-low",
- "gpt-5-low",
- "gpt-5-mini-low",
- "gpt-5-nano-low",
- "o1-low",
- "o4-mini-low",
- "o3-low",
- "sonnet-3.7-low",
- # Reasoning models on medium setting
- "gemini-2.5-flash-medium",
- "gemini-2.5-pro-medium",
- "gpt-5-medium",
- "gpt-5-mini-medium",
- "gpt-5-nano-medium",
- "o1-medium",
- "o4-mini-medium",
- "sonnet-3.7-medium",
- ],
-)
-async def test_openai_models_return_non_empty(model_key: str) -> None:
- """Call each OpenAI model key with a simple prompt and assert non-empty output."""
- result = await run_llm_async(
- model=model_key,
- system_prompt_text="You are a terse assistant.",
- user_message_text="Reply with a short friendly greeting.",
- output_json=False,
- pydantic_class=None,
- timeout=60,
- )
-
- assert isinstance(result, str), "LLM result should be a string"
- assert result.strip() != "", f"LLM result should not be empty for {model_key}"
diff --git a/tests/test_llm_ocr.py b/tests/test_llm_ocr.py
deleted file mode 100644
index d86590b..0000000
--- a/tests/test_llm_ocr.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-import pytest
-
-from churro.systems.llm_ocr import ZeroShotLLMOCR
-
-
-@pytest.mark.asyncio
-async def test_llm_ocr() -> None:
- """Test that the LLM OCR returns a sufficiently long transcription for a sample image."""
- image_path: str = str(Path(__file__).with_name("churro_dataset_sample_1.jpeg"))
- output: str = await ZeroShotLLMOCR(engine="gpt-4.1-mini").process_image_from_file(image_path)
-
- assert len(output) > 500, "OCR output is unexpectedly short"
diff --git a/tests/test_logging.py b/tests/test_logging.py
new file mode 100644
index 0000000..8d08121
--- /dev/null
+++ b/tests/test_logging.py
@@ -0,0 +1,41 @@
+from __future__ import annotations
+
+from churro_ocr._internal import logging as churro_logging
+
+
+def test_logger_adapter_formats_stdlib_style_messages() -> None:
+ records: list[tuple[str, str]] = []
+
+ class FakeLogger:
+ def info(self, message: str) -> None:
+ records.append(("info", message))
+
+ def warning(self, message: str) -> None:
+ records.append(("warning", message))
+
+ def error(self, message: str) -> None:
+ records.append(("error", message))
+
+ def debug(self, message: str) -> None:
+ records.append(("debug", message))
+
+ def critical(self, message: str) -> None:
+ records.append(("critical", message))
+
+ def exception(self, message: str) -> None:
+ records.append(("exception", message))
+
+ def log(self, level: str, message: str) -> None:
+ records.append((level, message))
+
+ logger = churro_logging._LoggerAdapter(FakeLogger())
+
+ logger.info("Page %s edge %s", 3, "left")
+ logger.error("Failed to parse %s", "response.json")
+ logger.log("INFO", "Value=%s", 42)
+
+ assert records == [
+ ("info", "Page 3 edge left"),
+ ("error", "Failed to parse response.json"),
+ ("INFO", "Value=42"),
+ ]
diff --git a/tests/test_model_map.py b/tests/test_model_map.py
deleted file mode 100644
index 32f580c..0000000
--- a/tests/test_model_map.py
+++ /dev/null
@@ -1,36 +0,0 @@
-"""Tests for the LLM model registry configuration."""
-
-from __future__ import annotations
-
-from churro.utils.llm.config import LLMSettings, get_settings
-from churro.utils.llm.models import reload_model_map
-
-
-def test_reload_model_map_applies_custom_settings() -> None:
- """Custom settings should flow into vertex and local vLLM entries."""
- original_settings = get_settings()
- custom_settings = LLMSettings(
- azure_api_base=original_settings.azure_api_base,
- azure_api_version=original_settings.azure_api_version,
- azure_openai_api_key=original_settings.azure_openai_api_key,
- local_vllm_port=4321,
- vertex_ai_location="us-test1",
- )
- assert custom_settings.vertex_ai_location == "us-test1"
- assert custom_settings.local_base_url == "http://localhost:4321/v1"
- try:
- new_map = reload_model_map(custom_settings)
-
- gemini_entry = new_map["gemini-2.5-flash-noreasoning"][0]
- gemini_params = gemini_entry.get("static_params") or {}
- assert gemini_params.get("vertex_location") == custom_settings.vertex_ai_location, (
- "Gemini vertex location should reflect injected settings"
- )
-
- churro_entry = new_map["churro"][0]
- churro_params = churro_entry.get("static_params") or {}
- assert churro_params.get("api_base") == custom_settings.local_base_url, (
- "Local vLLM endpoints should respect injected port"
- )
- finally:
- reload_model_map(original_settings)
diff --git a/tests/test_ocr_api.py b/tests/test_ocr_api.py
new file mode 100644
index 0000000..31f5f26
--- /dev/null
+++ b/tests/test_ocr_api.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+import pytest
+from PIL import Image
+
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.ocr import OCRBackend, OCRClient, OCRResult, prepare_ocr_page
+from churro_ocr.page_detection import DocumentPage
+from churro_ocr.prompts import strip_ocr_output_tag
+
+
+class _FakeOCRBackend(OCRBackend):
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ image = page.image
+ return OCRResult(
+ text=f"{image.width}x{image.height}",
+ provider_name="fake",
+ model_name="fake-model",
+ )
+
+
+def test_document_page_loads_image_from_path(write_image_file) -> None:
+ image_path = write_image_file(size=(12, 34))
+ page = DocumentPage.from_image_path(image_path)
+ image = page.image
+
+ assert image.size == (12, 34)
+
+
+def test_ocr_client_sync(write_image_file) -> None:
+ image_path = write_image_file(size=(20, 10))
+ result = OCRClient(_FakeOCRBackend()).ocr(DocumentPage.from_image_path(image_path))
+
+ assert result.text == "20x10"
+ assert result.provider_name == "fake"
+
+
+@pytest.mark.asyncio
+async def test_ocr_client_async_with_callable() -> None:
+ async def _callable_backend(page: DocumentPage) -> OCRResult:
+ image = page.image
+ return OCRResult(
+ text=str(image.size),
+ provider_name="callable",
+ model_name="callable-model",
+ )
+
+ result = await OCRClient(_callable_backend).aocr(
+ DocumentPage.from_image(Image.new("RGB", (9, 7), color="white"))
+ )
+
+ assert result.text == "(9, 7)"
+ assert result.provider_name == "callable"
+
+
+def test_prepare_ocr_page_resizes_and_normalizes_image() -> None:
+ page = DocumentPage.from_image(Image.new("RGBA", (5_000, 3_000), color=(255, 255, 255, 255)))
+
+ prepared_page = prepare_ocr_page(page)
+
+ assert page.image.size == (5_000, 3_000)
+ assert page.image.mode == "RGBA"
+ assert prepared_page.image.size == (2_500, 1_500)
+ assert prepared_page.image.mode == "RGB"
+
+
+def test_ocr_client_image_helpers_require_exactly_one_input(write_image_file) -> None:
+ image_path = write_image_file(size=(20, 10))
+ client = OCRClient(_FakeOCRBackend())
+
+ with pytest.raises(ConfigurationError, match="exactly one"):
+ client.ocr_image()
+
+ with pytest.raises(ConfigurationError, match="exactly one"):
+ client.ocr_image(
+ image=Image.new("RGB", (20, 10), color="white"),
+ image_path=image_path,
+ )
+
+
+@pytest.mark.parametrize(
+ ("text", "expected"),
+ [
+ ("\nhello\n ", "hello"),
+ (" hello", "hello"),
+ ("hello ", "hello"),
+ ("plain text", "plain text"),
+ ],
+)
+def test_strip_ocr_output_tag_removes_outer_and_stray_tags(text: str, expected: str) -> None:
+ assert strip_ocr_output_tag(text) == expected
diff --git a/tests/test_package_check.py b/tests/test_package_check.py
new file mode 100644
index 0000000..4ee8953
--- /dev/null
+++ b/tests/test_package_check.py
@@ -0,0 +1,45 @@
+from email.message import Message
+from importlib import metadata as importlib_metadata
+from importlib.util import module_from_spec, spec_from_file_location
+from pathlib import Path
+
+import pytest
+
+
+def _load_package_check_module():
+ path = Path(__file__).resolve().parents[1] / "scripts" / "package_check.py"
+ spec = spec_from_file_location("package_check", path)
+ assert spec is not None
+ assert spec.loader is not None
+ module = module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+def _metadata_message(*requirements: str) -> Message:
+ message = Message()
+ for requirement in requirements:
+ message.add_header("Requires-Dist", requirement)
+ return message
+
+
+package_check = _load_package_check_module()
+
+
+def test_license_audit_skips_missing_optional_extra_dependency(monkeypatch: pytest.MonkeyPatch) -> None:
+ def _always_missing(_: str):
+ raise importlib_metadata.PackageNotFoundError
+
+ monkeypatch.setattr(package_check.metadata, "distribution", _always_missing)
+
+ package_check._audit_dependency_licenses(_metadata_message('vllm<1,>=0.18; extra == "vllm"'))
+
+
+def test_license_audit_fails_for_missing_base_dependency(monkeypatch: pytest.MonkeyPatch) -> None:
+ def _always_missing(_: str):
+ raise importlib_metadata.PackageNotFoundError
+
+ monkeypatch.setattr(package_check.metadata, "distribution", _always_missing)
+
+ with pytest.raises(RuntimeError, match="pillow \\(not installed in the Pixi audit environment\\)"):
+ package_check._audit_dependency_licenses(_metadata_message("Pillow<12,>=10.4.0"))
diff --git a/tests/test_page_detection_integration.py b/tests/test_page_detection_integration.py
new file mode 100644
index 0000000..140883e
--- /dev/null
+++ b/tests/test_page_detection_integration.py
@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+import pytest
+from PIL import Image, ImageDraw
+
+from churro_ocr.page_detection import DocumentPage, DocumentPageDetector, PageDetectionRequest
+from churro_ocr.providers import LiteLLMTransportConfig, LLMPageDetector
+
+_LIVE_FLAG = "CHURRO_RUN_LIVE_VERTEX_TESTS"
+_MODEL = "vertex_ai/gemini-3.1-pro-preview"
+
+
+def _load_dotenv_if_present(path: Path) -> None:
+ if not path.exists():
+ return
+ for raw_line in path.read_text().splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#") or "=" not in line:
+ continue
+ if line.startswith("export "):
+ line = line[len("export ") :]
+ key, value = line.split("=", 1)
+ key = key.strip()
+ value = value.strip().strip("'").strip('"')
+ if not key or key in os.environ:
+ continue
+ if key == "GOOGLE_APPLICATION_CREDENTIALS" and value:
+ candidate = Path(value)
+ if not candidate.is_absolute():
+ value = str((path.parent / candidate).resolve())
+ os.environ[key] = value
+
+
+def _load_local_vertex_env() -> None:
+ churro_root = Path(__file__).resolve().parents[1]
+ repo_root = churro_root.parent
+ _load_dotenv_if_present(repo_root / ".env")
+ _load_dotenv_if_present(churro_root / ".env")
+
+
+def _write_synthetic_page_image(path: Path) -> tuple[int, int]:
+ width = 1200
+ height = 900
+ image = Image.new("RGB", (width, height), color=(242, 240, 236))
+ draw = ImageDraw.Draw(image)
+
+ left = 140
+ top = 90
+ right = 1020
+ bottom = 800
+ draw.rectangle((left + 20, top + 24, right + 30, bottom + 34), fill=(184, 178, 166))
+ draw.rectangle((left, top, right, bottom), fill=(255, 252, 244), outline=(60, 56, 50), width=6)
+
+ header_y = top + 70
+ draw.rectangle((left + 90, header_y, right - 90, header_y + 16), fill=(48, 44, 40))
+ body_top = header_y + 70
+ for index in range(12):
+ line_top = body_top + (index * 34)
+ line_right = right - 100 - ((index % 3) * 80)
+ draw.rectangle((left + 80, line_top, line_right, line_top + 10), fill=(70, 66, 61))
+
+ note_box = (right - 250, bottom - 170, right - 70, bottom - 70)
+ draw.rectangle(note_box, outline=(90, 70, 40), width=4)
+ draw.line((left - 40, top + 180, left - 10, top + 230), fill=(150, 150, 150), width=4)
+ image.save(path)
+ return width, height
+
+
+def _save_detection_overlay(
+ *,
+ source_path: Path,
+ overlay_path: Path,
+ pages: list[DocumentPage],
+) -> None:
+ image = Image.open(source_path).convert("RGB")
+ draw = ImageDraw.Draw(image)
+ for page in pages:
+ if page.polygon:
+ draw.polygon(page.polygon, outline=(220, 40, 40), width=8)
+ label_anchor = page.polygon[0]
+ elif page.bbox is not None:
+ draw.rectangle(page.bbox, outline=(220, 40, 40), width=8)
+ label_anchor = (page.bbox[0], page.bbox[1])
+ else:
+ continue
+ draw.text((label_anchor[0] + 12, label_anchor[1] + 12), f"page {page.page_index}", fill=(20, 90, 220))
+ image.save(overlay_path)
+
+
+@pytest.mark.integration
+@pytest.mark.asyncio
+async def test_llm_page_detector_live_vertex_gemini_31_pro(test_artifact_dir_path) -> None:
+ if os.getenv(_LIVE_FLAG) != "1":
+ pytest.skip(f"Set {_LIVE_FLAG}=1 to run live Vertex page-detection integration tests.")
+
+ _load_local_vertex_env()
+ missing = [key for key in ("GOOGLE_CLOUD_PROJECT", "VERTEX_AI_LOCATION") if not os.getenv(key)]
+ if missing:
+ pytest.skip(f"Missing required Vertex env vars: {', '.join(missing)}")
+
+ 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)
+
+ detector = DocumentPageDetector(
+ backend=LLMPageDetector(
+ model=_MODEL,
+ transport=LiteLLMTransportConfig(
+ image_detail="",
+ completion_kwargs={
+ "vertex_location": os.environ["VERTEX_AI_LOCATION"],
+ "reasoning_effort": "high",
+ },
+ ),
+ max_review_rounds=1,
+ )
+ )
+
+ result = await detector.detect_image(PageDetectionRequest(image_path=image_path, trim_margin=0))
+ overlay_path = test_artifact_dir_path / "vertex-page-detection-overlay.png"
+ _save_detection_overlay(
+ source_path=image_path,
+ overlay_path=overlay_path,
+ pages=result.pages,
+ )
+
+ assert result.source_type == "image"
+ assert len(result.pages) >= 1
+
+ first_page = result.pages[0]
+ assert first_page.metadata["detector"] == "llm"
+ assert first_page.bbox is not None
+ left, top, right, bottom = first_page.bbox
+ assert 0.0 <= left < right <= float(width)
+ assert 0.0 <= top < bottom <= float(height)
+ assert left > 40.0
+ assert top > 30.0
+ assert right < float(width - 40)
+ assert bottom < float(height - 40)
+ assert first_page.width < width
+ assert first_page.height < height
diff --git a/tests/test_page_detection_provider_helpers.py b/tests/test_page_detection_provider_helpers.py
new file mode 100644
index 0000000..1b45bb3
--- /dev/null
+++ b/tests/test_page_detection_provider_helpers.py
@@ -0,0 +1,427 @@
+from __future__ import annotations
+
+from typing import Any, cast
+
+import pytest
+from PIL import Image
+
+from churro_ocr.errors import ProviderError
+from churro_ocr.providers.page_detection import (
+ _apply_box_review_decision,
+ _apply_edge_decision_to_coordinate,
+ _apply_page_review_stop_condition,
+ _BoxReviewDecision,
+ _build_edge_strip_review_preview,
+ _convert_strip_delta_to_local_delta,
+ _EdgeReviewDecision,
+ _is_oscillating_magnitude,
+ _merge_instruction_prompts,
+ _new_page_review_stop_state,
+ _PageBox,
+ _parse_page_boxes_json,
+ _parse_single_edge_review_decision_json,
+ _parse_text_block_box_json,
+ _parse_text_block_edge_review_decision_json,
+ _review_page_box,
+ _review_single_edge_from_strip,
+ _review_single_text_block_edge_from_strip,
+ _review_text_block_box,
+ _run_review_pipeline,
+ _select_more_expansive_oscillation_coordinate,
+ _strip_code_fence,
+)
+
+
+def test_strip_code_fence_removes_fenced_wrapper() -> None:
+ assert _strip_code_fence('```json\n{"pages": []}\n```') == '{"pages": []}'
+
+
+def test_parse_page_boxes_json_supports_fenced_json_and_sorts_boxes() -> None:
+ boxes = _parse_page_boxes_json(
+ """```json
+{"pages": [
+ {"page_index": 2, "left": 500, "top": 100, "right": 700, "bottom": 900},
+ {"page_index": 1, "left": 100, "top": 100, "right": 400, "bottom": 900}
+]}
+```"""
+ )
+
+ assert [box.page_index for box in boxes] == [1, 2]
+ assert boxes[0].xmin < boxes[0].xmax
+
+
+@pytest.mark.parametrize(
+ ("payload", "expected"),
+ [
+ ("[]", "JSON object"),
+ ('{"pages": {}}', "`pages` list"),
+ ('{"pages": ["bad"]}', "entry 0 must be an object"),
+ ],
+)
+def test_parse_page_boxes_json_rejects_invalid_payloads(payload: str, expected: str) -> None:
+ with pytest.raises(ProviderError, match=expected):
+ _parse_page_boxes_json(payload)
+
+
+def test_parse_text_block_box_json_supports_flat_nested_and_not_found_payloads() -> None:
+ flat = _parse_text_block_box_json('{"left": 10, "top": 20, "right": 30, "bottom": 40}')
+ 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 missing is None
+
+
+def test_parse_text_block_box_json_rejects_invalid_block_payload() -> None:
+ with pytest.raises(ProviderError, match="`block` must be an object or null"):
+ _parse_text_block_box_json('{"block": 1}')
+
+
+def test_parse_single_edge_review_decision_json_accepts_nested_and_top_level_payloads() -> None:
+ nested = _parse_single_edge_review_decision_json(
+ '{"page_index": 3, "edge": "left", "decision": {"action": "expand", "amount": 12}}'
+ )
+ top_level = _parse_single_edge_review_decision_json(
+ '{"page_index": 4, "edge": "top", "action": "no_change", "amount": 99}'
+ )
+
+ assert nested == (3, "left", _EdgeReviewDecision(action="expand", amount=12))
+ assert top_level == (4, "top", _EdgeReviewDecision(action="no_change", amount=0))
+
+
+def test_parse_single_edge_review_decision_json_rejects_invalid_edge() -> None:
+ with pytest.raises(ValueError, match="left/top/right/bottom"):
+ _parse_single_edge_review_decision_json(
+ '{"page_index": 1, "edge": "center", "action": "expand", "amount": 1}'
+ )
+
+
+def test_parse_text_block_edge_review_decision_json_validates_payload() -> None:
+ edge_name, decision = _parse_text_block_edge_review_decision_json(
+ '{"edge": "bottom", "decision": {"action": "shrink", "amount": 5}}'
+ )
+
+ assert edge_name == "bottom"
+ assert decision == _EdgeReviewDecision(action="shrink", amount=5)
+
+
+def test_build_edge_strip_review_preview_rejects_unknown_edges() -> None:
+ with pytest.raises(ValueError, match="Unsupported edge 'center'"):
+ _build_edge_strip_review_preview(
+ Image.new("RGB", (40, 40), color="white"),
+ _PageBox.from_json({"page_index": 0, "left": 200, "top": 200, "right": 800, "bottom": 800}),
+ "center",
+ )
+
+
+def test_merge_instruction_prompts_merges_non_empty_parts_and_rejects_empty_input() -> None:
+ assert _merge_instruction_prompts(" first ", None, "second") == "first\n\nsecond"
+ with pytest.raises(ValueError, match="at least one non-empty instruction prompt"):
+ _merge_instruction_prompts(None, " ")
+
+
+def test_apply_box_review_decision_uses_expected_page_index_and_min_span() -> None:
+ current_box = _PageBox.from_json({"page_index": 0, "left": 300, "top": 300, "right": 700, "bottom": 700})
+ reviewed = _apply_box_review_decision(
+ current_box,
+ _BoxReviewDecision(
+ page_index=99,
+ left=_EdgeReviewDecision(action="shrink", amount=500),
+ top=_EdgeReviewDecision(action="shrink", amount=500),
+ right=_EdgeReviewDecision(action="expand", amount=0),
+ bottom=_EdgeReviewDecision(action="expand", amount=0),
+ ),
+ expected_page_index=1,
+ )
+
+ assert reviewed.page_index == 1
+ assert reviewed.xmin < reviewed.xmax
+ assert reviewed.ymin < reviewed.ymax
+
+
+def test_apply_page_review_stop_condition_freezes_edges_after_stable_rounds() -> None:
+ prior_box = _PageBox.from_json({"page_index": 0, "left": 100, "top": 100, "right": 900, "bottom": 900})
+ small_shift = _PageBox.from_json({"page_index": 0, "left": 104, "top": 102, "right": 896, "bottom": 898})
+ page_state = _new_page_review_stop_state()
+
+ first = _apply_page_review_stop_condition(
+ prior_box=prior_box,
+ reviewed_box=small_shift,
+ page_state=page_state,
+ round_index=1,
+ )
+ second = _apply_page_review_stop_condition(
+ prior_box=first,
+ reviewed_box=small_shift,
+ page_state=page_state,
+ round_index=2,
+ )
+
+ assert first == prior_box
+ assert second == prior_box
+ assert all(bool(page_state[edge_name]["frozen"]) for edge_name in ("left", "top", "right", "bottom"))
+
+
+def test_page_detection_math_helpers_cover_expansion_and_oscillation_logic() -> None:
+ assert _convert_strip_delta_to_local_delta(200, strip_axis_pixels=50, local_axis_pixels=100) == 100
+ assert _convert_strip_delta_to_local_delta(0, strip_axis_pixels=50, local_axis_pixels=100) == 0
+ assert (
+ _apply_edge_decision_to_coordinate(
+ 100,
+ _EdgeReviewDecision(action="expand", amount=10),
+ is_min_edge=True,
+ )
+ == 90
+ )
+ assert (
+ _apply_edge_decision_to_coordinate(
+ 100,
+ _EdgeReviewDecision(action="shrink", amount=10),
+ is_min_edge=False,
+ )
+ == 90
+ )
+ assert _is_oscillating_magnitude(6, 8) is True
+ assert _is_oscillating_magnitude(0, 8) is False
+ assert (
+ _select_more_expansive_oscillation_coordinate(
+ edge_name="left",
+ prior_value=100,
+ candidate_value=120,
+ )
+ == 100
+ )
+ assert (
+ _select_more_expansive_oscillation_coordinate(
+ edge_name="right",
+ prior_value=700,
+ candidate_value=680,
+ )
+ == 700
+ )
+
+
+@pytest.mark.asyncio
+async def test_review_single_edge_from_strip_logs_mismatches_and_scales_amount(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ log_messages: list[str] = []
+
+ class FakeLogger:
+ def info(self, message: str, *args: object) -> None:
+ log_messages.append(message % args if args else message)
+
+ class FakeTransport:
+ def prepare_messages(
+ self,
+ *,
+ system_prompt: str | None,
+ user_prompt: str | None,
+ images: list[Image.Image],
+ ) -> list[dict[str, object]]:
+ assert system_prompt is None
+ assert user_prompt is not None
+ assert images and images[0].size == (20, 60)
+ return [{"role": "user", "content": [{"type": "text", "text": user_prompt}]}]
+
+ async def complete_text(
+ self,
+ *,
+ model: str,
+ messages: list[dict[str, object]],
+ output_json: bool,
+ ) -> str:
+ assert model == "example/model"
+ assert output_json is True
+ assert messages[0]["role"] == "user"
+ return '{"page_index": 99, "edge": "top", "action": "expand", "amount": 500}'
+
+ monkeypatch.setattr("churro_ocr.providers.page_detection.logger", FakeLogger())
+
+ decision = await _review_single_edge_from_strip(
+ model="example/model",
+ review_image=Image.new("RGB", (200, 100), color="white"),
+ strip_image=Image.new("RGB", (20, 60), color="white"),
+ strip_bounds=(0, 0, 20, 60),
+ edge_name="left",
+ page_index=1,
+ history_steps=2,
+ round_index=3,
+ transport=cast(Any, FakeTransport()),
+ )
+
+ assert decision == _EdgeReviewDecision(action="expand", amount=50)
+ assert any("page_index mismatch" in message for message in log_messages)
+ assert any("edge mismatch" in message for message in log_messages)
+
+
+@pytest.mark.asyncio
+async def test_review_single_text_block_edge_from_strip_logs_mismatch_and_scales_amount(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ log_messages: list[str] = []
+
+ class FakeLogger:
+ def info(self, message: str, *args: object) -> None:
+ log_messages.append(message % args if args else message)
+
+ class FakeTransport:
+ def prepare_messages(
+ self,
+ *,
+ system_prompt: str | None,
+ user_prompt: str | None,
+ images: list[Image.Image],
+ ) -> list[dict[str, object]]:
+ assert system_prompt is None
+ assert user_prompt is not None
+ assert images and images[0].size == (60, 20)
+ return [{"role": "user", "content": [{"type": "text", "text": user_prompt}]}]
+
+ async def complete_text(
+ self,
+ *,
+ model: str,
+ messages: list[dict[str, object]],
+ output_json: bool,
+ ) -> str:
+ assert model == "example/model"
+ assert output_json is True
+ assert messages[0]["role"] == "user"
+ return '{"edge": "left", "action": "shrink", "amount": 500}'
+
+ monkeypatch.setattr("churro_ocr.providers.page_detection.logger", FakeLogger())
+
+ decision = await _review_single_text_block_edge_from_strip(
+ model="example/model",
+ review_image=Image.new("RGB", (120, 100), color="white"),
+ strip_image=Image.new("RGB", (60, 20), color="white"),
+ strip_bounds=(0, 0, 60, 20),
+ edge_name="top",
+ block_tag="Paragraph",
+ block_text="Et fuit lux",
+ history_steps=1,
+ round_index=1,
+ transport=cast(Any, FakeTransport()),
+ )
+
+ assert decision == _EdgeReviewDecision(action="shrink", amount=100)
+ assert any("edge-review mismatch" in message for message in log_messages)
+
+
+@pytest.mark.asyncio
+async def test_review_page_box_falls_back_to_no_change_when_an_edge_review_fails(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ current_box = _PageBox.from_json({"page_index": 2, "left": 200, "top": 200, "right": 800, "bottom": 800})
+
+ async def _fake_review_single_edge_from_strip(**kwargs: object) -> _EdgeReviewDecision:
+ if kwargs["edge_name"] == "left":
+ raise RuntimeError("boom")
+ return _EdgeReviewDecision(action="no_change", amount=0)
+
+ monkeypatch.setattr(
+ "churro_ocr.providers.page_detection._review_single_edge_from_strip",
+ _fake_review_single_edge_from_strip,
+ )
+
+ reviewed = await _review_page_box(
+ image=Image.new("RGB", (400, 400), color="white"),
+ current_box=current_box,
+ history_steps=1,
+ round_index=1,
+ model="example/model",
+ transport=cast(Any, object()),
+ )
+
+ assert reviewed == current_box
+
+
+@pytest.mark.asyncio
+async def test_review_text_block_box_falls_back_to_no_change_when_an_edge_review_fails(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ current_box = _PageBox.from_json({"page_index": 3, "left": 150, "top": 250, "right": 850, "bottom": 750})
+
+ async def _fake_review_single_text_block_edge_from_strip(**kwargs: object) -> _EdgeReviewDecision:
+ if kwargs["edge_name"] == "bottom":
+ raise RuntimeError("boom")
+ return _EdgeReviewDecision(action="no_change", amount=0)
+
+ monkeypatch.setattr(
+ "churro_ocr.providers.page_detection._review_single_text_block_edge_from_strip",
+ _fake_review_single_text_block_edge_from_strip,
+ )
+
+ reviewed = await _review_text_block_box(
+ image=Image.new("RGB", (400, 400), color="white"),
+ current_box=current_box,
+ block_tag="Paragraph",
+ block_text="Et fuit lux",
+ history_steps=1,
+ round_index=1,
+ model="example/model",
+ transport=cast(Any, object()),
+ )
+
+ assert reviewed == current_box
+
+
+@pytest.mark.asyncio
+async def test_run_review_pipeline_stops_immediately_when_all_pages_are_frozen(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ initial_box = _PageBox.from_json({"page_index": 1, "left": 200, "top": 200, "right": 800, "bottom": 800})
+ review_called = {"value": False}
+
+ monkeypatch.setattr(
+ "churro_ocr.providers.page_detection._new_page_review_stop_state",
+ lambda: {
+ edge_name: {
+ "frozen": True,
+ "stable_rounds": 0,
+ "last_sign": None,
+ "last_mag": None,
+ }
+ for edge_name in ("left", "top", "right", "bottom")
+ },
+ )
+
+ 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")
+
+ result = await _run_review_pipeline(
+ initial_boxes=[initial_box],
+ max_review_rounds=2,
+ review_box=_review_box,
+ subject_name_singular="page",
+ subject_name_plural="pages",
+ )
+
+ assert result == [initial_box]
+ assert review_called["value"] is False
+
+
+@pytest.mark.asyncio
+async def test_run_review_pipeline_preserves_prior_boxes_on_exception_and_none_result() -> None:
+ first_box = _PageBox.from_json({"page_index": 1, "left": 100, "top": 100, "right": 400, "bottom": 400})
+ second_box = _PageBox.from_json({"page_index": 2, "left": 500, "top": 500, "right": 900, "bottom": 900})
+
+ async def _review_box(box: _PageBox, history_steps: int, round_index: int) -> _PageBox:
+ del history_steps, round_index
+ if box.page_index == 1:
+ raise RuntimeError("boom")
+ return cast(Any, None)
+
+ result = await _run_review_pipeline(
+ initial_boxes=[first_box, second_box],
+ max_review_rounds=1,
+ review_box=_review_box,
+ subject_name_singular="page",
+ subject_name_plural="pages",
+ )
+
+ assert result == [first_box, second_box]
diff --git a/tests/test_parallel_executor.py b/tests/test_parallel_executor.py
deleted file mode 100644
index 1c3fb51..0000000
--- a/tests/test_parallel_executor.py
+++ /dev/null
@@ -1,72 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-
-import pytest
-
-from churro.utils.concurrency import ParallelExecutor, RetryPolicy, run_async_in_parallel
-
-
-@pytest.mark.asyncio
-async def test_parallel_executor_runs_all_jobs() -> None:
- executor = ParallelExecutor(max_concurrency=2)
-
- async def echo(value: int) -> int:
- await asyncio.sleep(0)
- return value * 2
-
- results = await executor.map(echo, [1, 2, 3, 4])
- assert results == [2, 4, 6, 8]
-
-
-@pytest.mark.asyncio
-async def test_parallel_executor_retries_timeout() -> None:
- attempts: dict[int, int] = {}
-
- async def flaky(value: int) -> int:
- attempts[value] = attempts.get(value, 0) + 1
- if attempts[value] == 1:
- await asyncio.sleep(0.05)
- return value
-
- executor = ParallelExecutor(
- max_concurrency=1,
- retry_policy=RetryPolicy(max_attempts=2, timeout=0.01),
- )
- results = await executor.map(flaky, [0])
- assert results == [0]
- assert attempts[0] == 2
-
-
-@pytest.mark.asyncio
-async def test_parallel_executor_records_failure() -> None:
- async def boom(_: int) -> int:
- raise ValueError("nope")
-
- executor = ParallelExecutor(max_concurrency=1)
- results = await executor.map(boom, [1, 2])
- assert results == [None, None]
-
-
-@pytest.mark.asyncio
-async def test_parallel_executor_returns_exceptions() -> None:
- async def sometimes_fail(value: int) -> int:
- if value == 1:
- raise RuntimeError("boom")
- return value
-
- executor = ParallelExecutor(max_concurrency=2, return_exceptions=True)
- results = await executor.map(sometimes_fail, [0, 1, 2])
-
- assert results[0] == 0
- assert isinstance(results[1], RuntimeError)
- assert results[2] == 2
-
-
-@pytest.mark.asyncio
-async def test_run_async_in_parallel_wrapper() -> None:
- async def identity(value: int) -> int:
- return value
-
- results = await run_async_in_parallel(identity, [1, 2, 3], max_concurrency=2, desc="")
- assert results == [1, 2, 3]
diff --git a/tests/test_pdf_pipeline.py b/tests/test_pdf_pipeline.py
deleted file mode 100644
index 0b04a7b..0000000
--- a/tests/test_pdf_pipeline.py
+++ /dev/null
@@ -1,163 +0,0 @@
-from __future__ import annotations
-
-import asyncio
-from io import BytesIO
-from pathlib import Path
-
-from PIL import Image
-import pytest
-
-from churro.utils.llm import ImageDetail
-from churro.utils.pdf.runner import (
- IdentityTrimmer,
- LLMPageSplitter,
- PageProcessingStage,
- PageSplitGroup,
- PageSplitter,
- PageTrimmer,
- RasterTask,
- run_pdf_pipeline,
-)
-
-
-class _StubSplitter(PageSplitter):
- async def split(self, image: Image.Image) -> list[Image.Image]:
- return [image, image.copy()]
-
-
-class _RecordingTrimmer(PageTrimmer):
- def __init__(self) -> None:
- self.calls: list[int] = []
-
- async def trim(self, image: Image.Image) -> Image.Image:
- self.calls.append(id(image))
- return image
-
-
-@pytest.mark.asyncio
-async def test_llm_page_splitter_skips_narrow_pages(monkeypatch: pytest.MonkeyPatch) -> None:
- async def _fail_run_llm_async(
- model: str,
- system_prompt_text: str | None,
- user_message_text: str | None,
- user_message_image: Image.Image | list[Image.Image] | None = None,
- image_detail: ImageDetail | None = None,
- output_json: bool = False,
- pydantic_class: type | None = None,
- timeout: int = 0,
- ) -> str:
- raise AssertionError("LLM should not be invoked for narrow pages.")
-
- monkeypatch.setattr("churro.utils.pdf.runner.run_llm_async", _fail_run_llm_async)
-
- splitter = LLMPageSplitter(engine="dummy")
- image = Image.new("RGB", (600, 1200), color="white")
-
- result = await splitter.split(image)
-
- assert len(result) == 1
-
-
-@pytest.mark.asyncio
-async def test_llm_page_splitter_invokes_llm_for_wide_pages(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- called: dict[str, bool] = {"value": False}
-
- async def _stub_run_llm_async(
- model: str,
- system_prompt_text: str | None,
- user_message_text: str | None,
- user_message_image: Image.Image | list[Image.Image] | None = None,
- image_detail: ImageDetail | None = None,
- output_json: bool = False,
- pydantic_class: type | None = None,
- timeout: int = 0,
- ) -> str:
- called["value"] = True
- return "\n1\n "
-
- monkeypatch.setattr("churro.utils.pdf.runner.run_llm_async", _stub_run_llm_async)
-
- splitter = LLMPageSplitter(engine="dummy")
- image = Image.new("RGB", (1400, 900), color="white")
-
- result = await splitter.split(image)
-
- assert called["value"] is True
- assert len(result) == 1
-
-
-@pytest.mark.asyncio
-async def test_page_processing_stage_splits_and_trims() -> None:
- trimmer = _RecordingTrimmer()
- stage = PageProcessingStage(
- splitter=_StubSplitter(),
- trimmer=trimmer,
- concurrency_limit=1,
- )
-
- raster_queue: asyncio.Queue[RasterTask | None] = asyncio.Queue()
- processed_queue: asyncio.Queue[PageSplitGroup | None] = asyncio.Queue()
-
- workers = stage.spawn_workers(
- count=1, raster_queue=raster_queue, processed_queue=processed_queue
- )
-
- image = Image.new("RGB", (10, 10), color="white")
- buffer = BytesIO()
- image.save(buffer, format="PNG")
-
- await raster_queue.put(
- RasterTask(pdf_id=0, pdf_path="synthetic.pdf", page_index=0, png_bytes=buffer.getvalue())
- )
- await raster_queue.put(None)
-
- await raster_queue.join()
- await asyncio.gather(*workers)
-
- group = processed_queue.get_nowait()
- processed_queue.task_done()
- await processed_queue.join()
-
- assert group is not None
- assert len(group.images) == 2
- assert len(trimmer.calls) == 2
-
-
-class _PipelineStubSplitter:
- def __init__(self, *, engine: str) -> None:
- self.engine = engine
-
- async def split(self, image: Image.Image) -> list[Image.Image]:
- return [image]
-
-
-class _PipelineStubTrimmer(IdentityTrimmer):
- async def trim(self, image: Image.Image) -> Image.Image:
- return image
-
-
-@pytest.mark.asyncio
-async def test_run_pdf_pipeline_with_images(tmp_path: Path) -> None:
- def splitter_factory(engine: str) -> PageSplitter:
- return _PipelineStubSplitter(engine=engine)
-
- def trimmer_factory(enable_trim: bool) -> PageTrimmer:
- return _PipelineStubTrimmer() if enable_trim else IdentityTrimmer()
-
- image_path = tmp_path / "sample.png"
- Image.new("RGB", (12, 12), color="white").save(image_path)
-
- output_dir = tmp_path / "out"
- await run_pdf_pipeline(
- pdf_paths=[],
- output_dir=str(output_dir),
- engine="dummy",
- image_paths=[str(image_path)],
- splitter_factory=splitter_factory,
- trimmer_factory=trimmer_factory,
- )
-
- expected = output_dir / "sample_page_0000.png"
- assert expected.exists()
diff --git a/tests/test_pdf_pipeline_integration.py b/tests/test_pdf_pipeline_integration.py
deleted file mode 100644
index da353a8..0000000
--- a/tests/test_pdf_pipeline_integration.py
+++ /dev/null
@@ -1,179 +0,0 @@
-from __future__ import annotations
-
-from collections import Counter
-from collections.abc import Callable
-from concurrent.futures import Executor, Future
-from pathlib import Path
-from types import TracebackType
-
-from PIL import Image
-import pytest
-
-
-ASSETS_DIR = Path(__file__).resolve().parent.parent
-
-
-class _InlineExecutor(Executor):
- def __init__(self, *args: object, **kwargs: object) -> None:
- super().__init__()
-
- def submit(
- self, fn: Callable[..., object], /, *args: object, **kwargs: object
- ) -> Future[object]:
- future: Future[object] = Future()
- try:
- result: object = fn(*args, **kwargs)
- except Exception as exc:
- future.set_exception(exc)
- else:
- future.set_result(result)
- return future
-
- def shutdown(self, wait: bool = True, cancel_futures: bool = False) -> None:
- return None
-
- def __enter__(self) -> _InlineExecutor:
- return self
-
- def __exit__(
- self,
- exc_type: type[BaseException] | None,
- exc_value: BaseException | None,
- traceback: TracebackType | None,
- ) -> bool:
- return False
-
-
-@pytest.fixture(autouse=True)
-def inline_process_pool(monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setattr(
- "churro.utils.pdf.runner.ProcessPoolExecutor",
- _InlineExecutor,
- )
-
-
-class PassthroughSplitter:
- async def split(self, image: Image.Image) -> list[Image.Image]:
- return [image]
-
-
-class DuplicateFirstSplitter:
- def __init__(self) -> None:
- self._invocations = 0
-
- async def split(self, image: Image.Image) -> list[Image.Image]:
- self._invocations += 1
- if self._invocations == 1:
- # Return two copies to emulate a two-page spread.
- return [image.copy(), image.copy()]
- return [image]
-
-
-class NoOpTrimmer:
- async def trim(self, image: Image.Image) -> Image.Image:
- return image
-
-
-def _asset(name: str) -> Path:
- return ASSETS_DIR / name
-
-
-@pytest.mark.asyncio
-async def test_pdf_pipeline_produces_png_from_minimal_pdf(tmp_path: Path) -> None:
- from churro.utils.pdf.runner import run_pdf_pipeline
-
- output_dir = tmp_path / "out"
- pdf_path = _asset("minimal-document.pdf")
-
- await run_pdf_pipeline(
- pdf_paths=[str(pdf_path)],
- output_dir=str(output_dir),
- engine="gpt-5-low",
- raster_workers=1,
- page_workers=1,
- llm_concurrency_limit=1,
- splitter_factory=lambda _engine: PassthroughSplitter(),
- trimmer_factory=lambda _trim: NoOpTrimmer(),
- )
-
- pngs = sorted(output_dir.glob("*.png"))
- assert len(pngs) == 1
- assert pngs[0].name == "minimal-document_page_0000.png"
-
-
-@pytest.mark.asyncio
-async def test_pdf_pipeline_handles_mixed_inputs(tmp_path: Path) -> None:
- from churro.utils.pdf.runner import run_pdf_pipeline
-
- output_dir = tmp_path / "out"
- pdf_path = _asset("minimal-document.pdf")
- image_paths = [
- _asset("churro_dataset_sample_1.jpeg"),
- _asset("churro_dataset_sample_2.jpeg"),
- ]
-
- splitter = DuplicateFirstSplitter()
-
- await run_pdf_pipeline(
- pdf_paths=[str(pdf_path)],
- output_dir=str(output_dir),
- engine="gpt-5-low",
- raster_workers=1,
- page_workers=1,
- llm_concurrency_limit=1,
- image_paths=[str(path) for path in image_paths],
- splitter_factory=lambda _engine: splitter,
- trimmer_factory=lambda _trim: NoOpTrimmer(),
- )
-
- pngs = sorted(output_dir.glob("*.png"))
- assert pngs, "Expected PNG outputs to be written"
- names = [png.name for png in pngs]
- counts = Counter(names)
- assert counts["minimal-document_page_0000.png"] == 1
- assert counts["churro_dataset_sample_1_page_0000.png"] == 1
- assert counts["churro_dataset_sample_2_page_0000.png"] == 1
- assert sum(name.endswith("_page_0001.png") for name in names) == 1
- assert len(names) == 4
-
-
-def test_docs_to_images_cli_end_to_end(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
- from churro.cli.main import app
-
- output_dir = tmp_path / "out"
- pdf_path = _asset("minimal-document.pdf")
-
- monkeypatch.setattr(
- "churro.cli.docs_to_images.default_splitter_factory",
- lambda _engine: PassthroughSplitter(),
- )
- monkeypatch.setattr(
- "churro.cli.docs_to_images.default_trimmer_factory",
- lambda _trim: NoOpTrimmer(),
- )
-
- exit_code = app(
- [
- "docs-to-images",
- "--input-file",
- str(pdf_path),
- "--output-dir",
- str(output_dir),
- "--engine",
- "gpt-5-low",
- "--raster-workers",
- "1",
- "--page-workers",
- "1",
- "--llm-concurrency-limit",
- "1",
- "--no-trim",
- ],
- standalone_mode=False,
- )
-
- assert exit_code == 0
-
- pngs = sorted(output_dir.glob("*.png"))
- assert len(pngs) == 1
- assert pngs[0].name == "minimal-document_page_0000.png"
diff --git a/tests/test_process_one_pdf_file.py b/tests/test_process_one_pdf_file.py
deleted file mode 100644
index 255e1fc..0000000
--- a/tests/test_process_one_pdf_file.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""Tests for `run_pdf_pipeline` in `utils/pdf/`.
-
-This test focuses on verifying that image files are produced for a PDF input.
-We rely on the sample PDF `minimal-document.pdf` housed next to this test.
-"""
-
-from __future__ import annotations
-
-import os
-from pathlib import Path
-
-import fitz
-from PIL import Image
-import pytest
-
-from churro.utils.pdf import run_pdf_pipeline
-
-
-@pytest.mark.asyncio
-async def test_process_one_pdf_file(tmp_path: Path) -> None:
- """Process a PDF and ensure at least one output PNG is created.
-
- Assertions:
- * Exactly `total_pages` output PNG files created (since no splitting occurs)
- * Filenames follow the expected naming pattern `_page_XXXX.png`
- * Saved images can be opened by Pillow
- """
- # Arrange
- sample_pdf = Path(__file__).with_name("minimal-document.pdf")
- assert sample_pdf.exists(), "Sample PDF missing. Ensure it was downloaded before running tests."
-
- # Determine real page count so our stub aligns with batching logic
- with fitz.open(sample_pdf.as_posix()) as doc: # type: ignore[attr-defined]
- total_pages = doc.page_count
-
- # Defensive: sample file should have at least one page
- assert total_pages >= 1
-
- # Act
- output_dir = tmp_path / "out"
- output_dir.mkdir()
-
- await run_pdf_pipeline(
- pdf_paths=[sample_pdf.as_posix()],
- output_dir=output_dir.as_posix(),
- engine="gpt-4.1",
- )
-
- # Assert
- created_files = sorted(p for p in output_dir.glob("*.png"))
- assert len(created_files) == total_pages, (
- f"Expected {total_pages} output images, found {len(created_files)}"
- )
-
- # Validate naming pattern and that files are readable images
- base_name = sample_pdf.stem.replace(" ", "_")
- for idx, file_path in enumerate(created_files):
- expected = f"{base_name}_page_{idx:04d}.png"
- assert file_path.name == expected, (
- f"Unexpected filename {file_path.name}, expected {expected}"
- )
- with Image.open(file_path) as img:
- img.verify() # Ensures file is a valid image
-
- # Cleanup is implicit via tmp_path fixture; still assert directory removal works when manually invoked
- # This ensures no lingering file locks.
- for f in created_files:
- os.remove(f)
- assert not any(output_dir.glob("*.png")), "Output PNG files not cleaned up"
-
-
-@pytest.mark.asyncio
-async def test_process_image_directory(tmp_path: Path) -> None:
- image_dir = Path(__file__).parent
-
- output_dir = tmp_path / "out"
- output_dir.mkdir()
-
- await run_pdf_pipeline(
- pdf_paths=[],
- output_dir=output_dir.as_posix(),
- engine="gpt-4.1",
- trim=False,
- image_dir=image_dir.as_posix(),
- )
-
- created_files = sorted(output_dir.glob("*.png"))
- assert len(created_files) == 3, (
- f"Expected 3 output images, found {len(created_files)}"
- ) # The directory contains a one-page image and a two-page image
- for file_path in created_files:
- with Image.open(file_path) as img:
- img.verify() # Ensures file is a valid image
diff --git a/tests/test_provider_api_contracts.py b/tests/test_provider_api_contracts.py
new file mode 100644
index 0000000..bde918f
--- /dev/null
+++ b/tests/test_provider_api_contracts.py
@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+import re
+from typing import Any, cast
+
+import pytest
+
+import churro_ocr.providers as providers
+from churro_ocr.errors import ConfigurationError
+from churro_ocr.providers import (
+ AzureDocumentIntelligenceOptions,
+ HuggingFaceOptions,
+ LiteLLMTransportConfig,
+ MistralOptions,
+ OCRBackendSpec,
+ OpenAICompatibleOptions,
+ VLLMOptions,
+ build_ocr_backend,
+ resolve_ocr_profile,
+)
+from churro_ocr.providers.builder import _merge_mapping
+from churro_ocr.providers.ocr import (
+ AzureDocumentIntelligenceOCRBackend,
+ MistralOCRBackend,
+ OpenAICompatibleOCRBackend,
+)
+
+
+def test_merge_mapping_merges_nested_dictionaries() -> None:
+ merged = _merge_mapping(
+ {"outer": {"left": 1, "right": 2}, "flat": 1},
+ {"outer": {"right": 3, "bottom": 4}, "flat": 2},
+ )
+
+ assert merged == {"outer": {"left": 1, "right": 3, "bottom": 4}, "flat": 2}
+
+
+def test_resolve_ocr_profile_rejects_unknown_profile_name() -> None:
+ with pytest.raises(ValueError, match="Unknown OCR profile 'missing-profile'"):
+ resolve_ocr_profile(model_id=None, profile="missing-profile")
+
+
+def test_provider_lazy_exports_reject_unknown_attributes() -> None:
+ with pytest.raises(AttributeError, match="has no attribute 'missing_export'"):
+ providers.__getattr__("missing_export")
+
+
+def test_provider_dir_lists_lazy_exports() -> None:
+ exported = providers.__dir__()
+
+ assert "build_ocr_backend" in exported
+ assert "OCRBackendSpec" in exported
+ assert "LiteLLMTransportConfig" in exported
+
+
+@pytest.mark.parametrize(
+ ("spec", "expected"),
+ [
+ (OCRBackendSpec(provider="litellm"), "OCR provider 'litellm' requires `model`."),
+ (
+ OCRBackendSpec(provider="openai-compatible", model="local-model"),
+ "OCR provider 'openai-compatible' requires `transport.api_base` and `transport.api_key`.",
+ ),
+ (
+ OCRBackendSpec(provider="azure"),
+ "OCR provider 'azure' requires AzureDocumentIntelligenceOptions(endpoint=..., api_key=...).",
+ ),
+ (
+ OCRBackendSpec(provider="mistral"),
+ "OCR provider 'mistral' requires MistralOptions(api_key=...).",
+ ),
+ (
+ OCRBackendSpec(
+ provider="hf",
+ model="example/model",
+ options=cast("Any", VLLMOptions()),
+ ),
+ "OCR provider 'hf' requires options of type HuggingFaceOptions, got VLLMOptions.",
+ ),
+ (
+ OCRBackendSpec(
+ provider="vllm",
+ model="example/model",
+ options=cast("Any", HuggingFaceOptions()),
+ ),
+ "OCR provider 'vllm' requires options of type VLLMOptions, got HuggingFaceOptions.",
+ ),
+ ],
+)
+def test_build_ocr_backend_validation_errors(spec: OCRBackendSpec, expected: str) -> None:
+ with pytest.raises(ConfigurationError, match=re.escape(expected)):
+ build_ocr_backend(spec)
+
+
+def test_build_ocr_backend_supports_custom_openai_model_prefix() -> None:
+ backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="openai-compatible",
+ model="local-model",
+ transport=LiteLLMTransportConfig(
+ api_base="http://127.0.0.1:8000/v1",
+ api_key="dummy",
+ ),
+ options=OpenAICompatibleOptions(model_prefix="custom"),
+ )
+ )
+
+ assert isinstance(backend, OpenAICompatibleOCRBackend)
+ assert backend.model == "custom/local-model"
+ assert backend.model_name == "local-model"
+
+
+def test_build_ocr_backend_rejects_unknown_provider() -> None:
+ with pytest.raises(ConfigurationError, match="Unsupported OCR provider 'bogus'"):
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider=cast("Any", "bogus"),
+ model="example/model",
+ )
+ )
+
+
+def test_build_ocr_backend_accepts_provider_specific_options() -> None:
+ azure_backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="azure",
+ model="layout-model",
+ options=AzureDocumentIntelligenceOptions(
+ endpoint="https://example.invalid",
+ api_key="secret",
+ ),
+ )
+ )
+ mistral_backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="mistral",
+ options=MistralOptions(api_key="secret"),
+ )
+ )
+
+ assert isinstance(azure_backend, AzureDocumentIntelligenceOCRBackend)
+ assert isinstance(mistral_backend, MistralOCRBackend)
+ assert azure_backend.model_id == "layout-model"
+ assert azure_backend.model_name == "layout-model"
+ assert mistral_backend.model == "mistral-ocr-latest"
diff --git a/tests/test_providers.py b/tests/test_providers.py
new file mode 100644
index 0000000..065d08b
--- /dev/null
+++ b/tests/test_providers.py
@@ -0,0 +1,1071 @@
+from __future__ import annotations
+
+import base64
+import json
+import sys
+from types import ModuleType, SimpleNamespace
+from typing import Any, cast
+
+import pytest
+from PIL import Image
+
+from churro_ocr._internal.litellm import LiteLLMTransport
+from churro_ocr.errors import ProviderError
+from churro_ocr.page_detection import DocumentPage
+from churro_ocr.prompts import DEFAULT_BOUNDARY_DETECTION_PROMPT, DEFAULT_OCR_OUTPUT_TAG
+from churro_ocr.providers import (
+ AzureDocumentIntelligenceOptions,
+ AzurePageDetector,
+ HuggingFaceOptions,
+ LiteLLMTransportConfig,
+ LLMPageDetector,
+ MistralOptions,
+ OCRBackendSpec,
+ OpenAICompatibleOptions,
+ VLLMOptions,
+ build_ocr_backend,
+ locate_text_block_bbox_with_llm,
+ resolve_ocr_profile,
+)
+from churro_ocr.providers.hf import HuggingFaceVisionOCRBackend
+from churro_ocr.providers.ocr import (
+ AzureDocumentIntelligenceOCRBackend,
+ LiteLLMVisionOCRBackend,
+ MistralOCRBackend,
+)
+from churro_ocr.providers.page_detection import (
+ _normalize_azure_page_polygon,
+ _PageBox,
+ locate_text_block_bbox_with_llm_sync,
+)
+from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS
+from churro_ocr.providers.vllm import VLLMVisionOCRBackend
+from churro_ocr.templates import DEFAULT_OCR_TEMPLATE
+
+
+def _extract_user_text_parts(messages: list[dict[str, Any]]) -> list[str]:
+ user_messages = [message for message in messages if message.get("role") == "user"]
+ assert len(user_messages) == 1
+ content = cast("list[dict[str, Any]]", user_messages[0]["content"])
+ return [
+ cast("str", item["text"])
+ for item in content
+ if item.get("type") == "text" and isinstance(item.get("text"), str)
+ ]
+
+
+@pytest.mark.asyncio
+async def test_litellm_ocr_backend_uses_transport(monkeypatch: pytest.MonkeyPatch) -> None:
+ image = Image.new("RGB", (10, 10), color="white")
+ page = DocumentPage(page_index=0, image=image, source_index=0)
+ prompt_logs: list[str] = []
+
+ class FakeLogger:
+ def debug(self, message: str, *args: object) -> None:
+ prompt_logs.append(message % args if args else message)
+
+ def _fake_prepare_messages(
+ conversation: list[dict[str, object]],
+ *,
+ image_detail: str | None,
+ ) -> list[dict[str, object]]:
+ content = cast("list[dict[str, object]]", conversation[1]["content"])
+ assert content[0]["image"] == image
+ assert image_detail == "high"
+ return [{"role": "user", "content": [{"type": "text", "text": "prompt"}]}]
+
+ async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001
+ return "transcribed text"
+
+ monkeypatch.setattr(
+ "churro_ocr._internal.litellm._prepare_messages_from_conversation", _fake_prepare_messages
+ )
+ monkeypatch.setattr(
+ "churro_ocr._internal.litellm.LiteLLMTransport.complete_text",
+ _fake_complete_text,
+ )
+ monkeypatch.setattr("churro_ocr._internal.prompt_logging.logger", FakeLogger())
+
+ backend = cast(
+ "LiteLLMVisionOCRBackend",
+ build_ocr_backend(OCRBackendSpec(provider="litellm", model="gpt-4.1-mini")),
+ )
+ result = await backend.ocr(page)
+
+ assert result.text == "transcribed text"
+ assert result.model_name == "gpt-4.1-mini"
+ assert backend.transport.config.completion_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS}
+ assert len(prompt_logs) == 1
+ assert "First OCR prompt payload for litellm" in prompt_logs[0]
+
+
+@pytest.mark.asyncio
+async def test_litellm_ocr_backend_logs_prompt_only_once(monkeypatch: pytest.MonkeyPatch) -> None:
+ prompt_logs: list[str] = []
+
+ 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
+ return "ok"
+
+ monkeypatch.setattr(
+ "churro_ocr._internal.litellm._prepare_messages_from_conversation",
+ lambda *_args, **_kwargs: [
+ {
+ "role": "user",
+ "content": [
+ {
+ "type": "image_url",
+ "image_url": {"url": "data:image/png;base64,abcdefghijklmnopqrstuvwxyz"},
+ },
+ {"type": "text", "text": "prompt"},
+ ],
+ }
+ ],
+ )
+ monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text)
+ monkeypatch.setattr("churro_ocr._internal.prompt_logging.logger", FakeLogger())
+
+ backend = build_ocr_backend(OCRBackendSpec(provider="litellm", model="gpt-4.1-mini"))
+ page = DocumentPage.from_image(Image.new("RGB", (10, 10), color="white"))
+
+ assert (await backend.ocr(page)).text == "ok"
+ assert (await backend.ocr(page)).text == "ok"
+ assert len(prompt_logs) == 1
+ assert "data:image/png;base64,abcdefghijklmnopqrstuvwxyz" in prompt_logs[0]
+
+
+@pytest.mark.asyncio
+async def test_litellm_transport_tracks_total_cost(monkeypatch: pytest.MonkeyPatch) -> None:
+ async def _fake_acompletion(**_: object) -> SimpleNamespace:
+ return SimpleNamespace(
+ choices=[SimpleNamespace(message=SimpleNamespace(content="tracked text"))],
+ _hidden_params={"response_cost": 0.125},
+ )
+
+ fake_module = ModuleType("litellm")
+ cast("Any", fake_module).acompletion = _fake_acompletion
+ cast("Any", fake_module).completion_cost = lambda **_: 999.0
+
+ monkeypatch.setitem(sys.modules, "litellm", fake_module)
+ monkeypatch.setattr("churro_ocr._internal.litellm._ensure_initialized", lambda: None)
+
+ transport = LiteLLMTransport()
+ result = await transport.complete_text(
+ model="example/model",
+ messages=[{"role": "user", "content": [{"type": "text", "text": "hello"}]}],
+ )
+
+ assert result == "tracked text"
+ assert transport.last_cost_usd == pytest.approx(0.125)
+ assert transport.total_cost_usd == pytest.approx(0.125)
+ assert transport.request_count == 1
+ assert transport.untracked_request_count == 0
+
+
+@pytest.mark.asyncio
+async def test_litellm_ocr_backend_strips_default_output_tags(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001
+ return f"<{DEFAULT_OCR_OUTPUT_TAG}>\ntranscribed text\n{DEFAULT_OCR_OUTPUT_TAG}>"
+
+ monkeypatch.setattr(
+ "churro_ocr._internal.litellm._prepare_messages_from_conversation",
+ lambda *_args, **_kwargs: [],
+ )
+ monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text)
+
+ backend = build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model="gpt-4.1-mini",
+ profile=resolve_ocr_profile(model_id="gpt-4.1-mini"),
+ )
+ )
+ result = await backend.ocr(DocumentPage.from_image(Image.new("RGB", (10, 10), color="white")))
+
+ assert result.text == "transcribed text"
+
+
+@pytest.mark.asyncio
+async def test_openai_compatible_backend_reports_display_model(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001
+ return "openai compatible text"
+
+ monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text)
+
+ backend = cast(
+ "LiteLLMVisionOCRBackend",
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="openai-compatible",
+ model="local-model",
+ transport=LiteLLMTransportConfig(
+ api_base="http://127.0.0.1:8000/v1",
+ api_key="dummy",
+ ),
+ options=OpenAICompatibleOptions(),
+ )
+ ),
+ )
+ result = await backend.ocr(DocumentPage.from_image(Image.new("RGB", (10, 10), color="white")))
+
+ assert result.provider_name == "openai-compatible"
+ assert result.model_name == "local-model"
+ assert backend.transport.config.completion_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS}
+
+
+@pytest.mark.asyncio
+async def test_azure_ocr_backend_reuses_client(monkeypatch: pytest.MonkeyPatch) -> None:
+ calls = {"client_inits": 0, "requests": 0}
+ image = Image.new("RGB", (10, 10), color="white")
+ encoded = base64.b64encode(b"image-bytes").decode("ascii")
+
+ class FakePoller:
+ async def result(self) -> SimpleNamespace:
+ return SimpleNamespace(content="azure text")
+
+ class FakeClient:
+ def __init__(self, *, endpoint: str, credential: Any) -> None:
+ calls["client_inits"] += 1
+ assert endpoint == "https://example.test"
+ assert credential.key == "secret"
+
+ async def begin_analyze_document(
+ self,
+ *,
+ model_id: str,
+ body: Any,
+ content_type: str,
+ ) -> FakePoller:
+ calls["requests"] += 1
+ assert model_id == "prebuilt-layout"
+ assert body.read() == b"image-bytes"
+ assert content_type == "application/octet-stream"
+ return FakePoller()
+
+ class FakeAzureKeyCredential:
+ def __init__(self, key: str) -> None:
+ self.key = key
+
+ azure_document_module = ModuleType("azure.ai.documentintelligence.aio")
+ cast("Any", azure_document_module).DocumentIntelligenceClient = FakeClient
+ azure_credentials_module = ModuleType("azure.core.credentials")
+ 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(
+ "churro_ocr.providers.ocr.image_to_base64",
+ lambda actual_image, format_name: (
+ (
+ "image/jpeg",
+ encoded,
+ )
+ if actual_image.size == image.size and actual_image.mode == "RGB" and format_name == "JPEG"
+ else ("", "")
+ ),
+ )
+
+ backend = cast(
+ "AzureDocumentIntelligenceOCRBackend",
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="azure",
+ options=AzureDocumentIntelligenceOptions(
+ endpoint="https://example.test",
+ api_key="secret",
+ ),
+ )
+ ),
+ )
+ page = DocumentPage(page_index=0, image=image, source_index=0)
+
+ first = await backend.ocr(page)
+ second = await backend.ocr(page)
+
+ assert first.text == "azure text"
+ assert second.text == "azure text"
+ assert calls == {"client_inits": 1, "requests": 2}
+
+
+@pytest.mark.asyncio
+async def test_mistral_ocr_backend_reuses_client(monkeypatch: pytest.MonkeyPatch) -> None:
+ calls = {"client_inits": 0, "requests": 0}
+ image = Image.new("RGB", (10, 10), color="white")
+
+ class FakeOCRNamespace:
+ async def process_async(self, *, model: str, document: dict[str, str]) -> SimpleNamespace:
+ calls["requests"] += 1
+ assert model == "mistral-ocr-latest"
+ assert document == {
+ "type": "image_url",
+ "image_url": "data:image/jpeg;base64,encoded-image",
+ }
+ return SimpleNamespace(pages=[SimpleNamespace(markdown="mistral text")])
+
+ class FakeMistralClient:
+ def __init__(self, *, api_key: str) -> None:
+ calls["client_inits"] += 1
+ assert api_key == "secret"
+ self.ocr = FakeOCRNamespace()
+
+ mistral_module = ModuleType("mistralai")
+ cast("Any", mistral_module).Mistral = FakeMistralClient
+ monkeypatch.setitem(sys.modules, "mistralai", mistral_module)
+ monkeypatch.setattr(
+ "churro_ocr.providers.ocr.image_to_base64",
+ lambda actual_image, format_name: (
+ (
+ "image/jpeg",
+ "encoded-image",
+ )
+ if actual_image.size == image.size and actual_image.mode == "RGB" and format_name == "JPEG"
+ else ("", "")
+ ),
+ )
+
+ backend = cast(
+ "MistralOCRBackend",
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="mistral",
+ options=MistralOptions(api_key="secret"),
+ )
+ ),
+ )
+ page = DocumentPage(page_index=0, image=image, source_index=0)
+
+ first = await backend.ocr(page)
+ second = await backend.ocr(page)
+
+ assert first.text == "mistral text"
+ assert second.text == "mistral text"
+ assert calls == {"client_inits": 1, "requests": 2}
+
+
+@pytest.mark.asyncio
+async def test_vllm_ocr_backend_reuses_engine_and_batches(monkeypatch: pytest.MonkeyPatch) -> None:
+ calls = {"processor_inits": 0, "engine_inits": 0}
+ captured: dict[str, object] = {}
+
+ class FakeProcessor:
+ tokenizer = None
+
+ def apply_chat_template(
+ self,
+ conversation: list[dict[str, object]],
+ *,
+ add_generation_prompt: bool,
+ tokenize: bool,
+ ) -> str:
+ assert add_generation_prompt is True
+ assert tokenize is False
+ content = cast("list[dict[str, object]]", conversation[0]["content"])
+ return f"prompt:{content[1]['text']}"
+
+ class FakeProcessorCls:
+ @staticmethod
+ def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor:
+ calls["processor_inits"] += 1
+ captured["processor_model_id"] = model_id
+ captured["processor_kwargs"] = kwargs
+ return FakeProcessor()
+
+ class FakeSamplingParams:
+ def __init__(self, **kwargs: object) -> None:
+ captured["sampling_kwargs"] = kwargs
+
+ class FakeLLM:
+ def __init__(self, **kwargs: object) -> None:
+ calls["engine_inits"] += 1
+ captured["llm_kwargs"] = kwargs
+
+ def generate(
+ self,
+ prompts: list[dict[str, object]],
+ sampling_params: FakeSamplingParams,
+ *,
+ use_tqdm: bool,
+ ) -> list[SimpleNamespace]:
+ del sampling_params
+ captured["prompts"] = prompts
+ captured["use_tqdm"] = use_tqdm
+ return [
+ SimpleNamespace(outputs=[SimpleNamespace(text=f"text:{index}")])
+ for index, _prompt in enumerate(prompts)
+ ]
+
+ vllm_module = ModuleType("vllm")
+ cast("Any", vllm_module).LLM = FakeLLM
+ cast("Any", vllm_module).SamplingParams = FakeSamplingParams
+ monkeypatch.setitem(sys.modules, "vllm", vllm_module)
+ monkeypatch.setattr("churro_ocr.providers.vllm._load_vllm_processor_cls", lambda: FakeProcessorCls)
+
+ backend = cast(
+ "VLLMVisionOCRBackend",
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="vllm",
+ model="kristaller486/dots.ocr-1.5",
+ options=VLLMOptions(),
+ )
+ ),
+ )
+ pages = [
+ DocumentPage.from_image(Image.new("RGBA", (5_000, 3_000), color=(255, 255, 255, 255))),
+ DocumentPage.from_image(Image.new("RGB", (12, 12), color="white")),
+ ]
+
+ first = await backend.ocr(pages[0])
+ second_batch = await backend.ocr_batch(pages)
+
+ assert first.text == "text:0"
+ assert [result.text for result in second_batch] == ["text:0", "text:1"]
+ assert calls == {"processor_inits": 1, "engine_inits": 1}
+ assert captured["processor_model_id"] == "kristaller486/dots.ocr-1.5"
+ assert captured["processor_kwargs"] == {"trust_remote_code": True}
+ assert captured["llm_kwargs"] == {
+ "model": "kristaller486/dots.ocr-1.5",
+ "trust_remote_code": True,
+ "limit_mm_per_prompt": {"image": 1},
+ }
+ assert captured["sampling_kwargs"] == {"max_tokens": DEFAULT_OCR_MAX_TOKENS}
+ assert captured["use_tqdm"] is False
+ prompt_batch = cast("list[dict[str, object]]", captured["prompts"])
+ assert isinstance(prompt_batch, list)
+ assert len(prompt_batch) == 2
+ assert prompt_batch[0]["prompt"] == "prompt:Extract the text content from this image."
+ prompt_media = cast("dict[str, object]", prompt_batch[0]["multi_modal_data"])
+ prompt_image = cast("Image.Image", prompt_media["image"])
+ assert isinstance(prompt_image, Image.Image)
+ assert prompt_image.size == (2_500, 1_500)
+ assert prompt_image.mode == "RGB"
+
+
+def test_vllm_backend_defaults_are_public() -> None:
+ backend = cast(
+ "VLLMVisionOCRBackend",
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="vllm",
+ model="kristaller486/dots.ocr-1.5",
+ options=VLLMOptions(),
+ )
+ ),
+ )
+
+ assert backend.model_id == "kristaller486/dots.ocr-1.5"
+ assert backend.model_name == "dots.ocr-1.5"
+ assert backend.provider_name == "vllm"
+ assert backend.processor_kwargs == {}
+
+
+@pytest.mark.asyncio
+async def test_vllm_ocr_backend_strips_default_output_tags(monkeypatch: pytest.MonkeyPatch) -> None:
+ class FakeProcessor:
+ tokenizer = None
+
+ def apply_chat_template(
+ self,
+ conversation: list[dict[str, object]],
+ *,
+ add_generation_prompt: bool,
+ tokenize: bool,
+ ) -> str:
+ del conversation, add_generation_prompt, tokenize
+ return "prompt"
+
+ class FakeProcessorCls:
+ @staticmethod
+ def from_pretrained(model_id: str, **kwargs: object) -> FakeProcessor:
+ del model_id, kwargs
+ return FakeProcessor()
+
+ class FakeSamplingParams:
+ def __init__(self, **kwargs: object) -> None:
+ del kwargs
+
+ class FakeLLM:
+ def __init__(self, **kwargs: object) -> None:
+ del kwargs
+
+ def generate(
+ self,
+ prompts: list[dict[str, object]],
+ sampling_params: FakeSamplingParams,
+ *,
+ use_tqdm: bool,
+ ) -> list[SimpleNamespace]:
+ del prompts, sampling_params, use_tqdm
+ return [
+ SimpleNamespace(
+ outputs=[
+ SimpleNamespace(
+ text=(f"<{DEFAULT_OCR_OUTPUT_TAG}>\npage text\n{DEFAULT_OCR_OUTPUT_TAG}>")
+ )
+ ]
+ )
+ ]
+
+ vllm_module = ModuleType("vllm")
+ cast("Any", vllm_module).LLM = FakeLLM
+ cast("Any", vllm_module).SamplingParams = FakeSamplingParams
+ monkeypatch.setitem(sys.modules, "vllm", vllm_module)
+ monkeypatch.setattr("churro_ocr.providers.vllm._load_vllm_processor_cls", lambda: FakeProcessorCls)
+
+ backend = cast(
+ "VLLMVisionOCRBackend",
+ build_ocr_backend(OCRBackendSpec(provider="vllm", model="example/model")),
+ )
+ result = await backend.ocr(DocumentPage.from_image(Image.new("RGB", (10, 10), color="white")))
+
+ assert result.text == "page text"
+
+
+@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
+ return json.dumps(
+ {
+ "pages": [
+ {"page_index": 0, "left": 0, "top": 0, "right": 400, "bottom": 750},
+ {"page_index": 1, "left": 450, "top": 0, "right": 800, "bottom": 750},
+ ]
+ }
+ )
+
+ monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text)
+
+ detector = LLMPageDetector(model="gemini-2.5-flash")
+ candidates = await detector.detect(Image.new("RGB", (100, 40), color="white"))
+
+ assert len(candidates) == 2
+ assert candidates[0].image is None
+ assert candidates[0].bbox is not None
+ assert candidates[1].metadata["detector"] == "llm"
+
+
+@pytest.mark.asyncio
+async def test_llm_page_detector_rejects_malformed_json(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_complete_text(self, **_: object) -> str: # noqa: ANN001
+ return '{"pages":"oops"}'
+
+ monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text)
+
+ detector = LLMPageDetector(model="gemini-2.5-flash")
+ with pytest.raises(ProviderError, match="`pages` list"):
+ await detector.detect(Image.new("RGB", (100, 40), color="white"))
+
+
+@pytest.mark.asyncio
+async def test_llm_page_detector_returns_full_image_candidate_when_initial_detection_is_empty(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_complete_page_boxes(**_: object) -> list[object]:
+ return []
+
+ monkeypatch.setattr(
+ "churro_ocr.providers.page_detection._complete_page_boxes",
+ _fake_complete_page_boxes,
+ )
+
+ image = Image.new("RGB", (100, 40), color="white")
+ detector = LLMPageDetector(model="gemini-2.5-flash")
+ candidates = await detector.detect(image)
+
+ assert len(candidates) == 1
+ assert candidates[0].bbox == (0.0, 0.0, 100.0, 40.0)
+ assert candidates[0].polygon == ()
+
+
+@pytest.mark.asyncio
+async def test_llm_page_detector_applies_iterative_review(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ responses = iter(
+ [
+ json.dumps(
+ {
+ "pages": [
+ {
+ "page_index": 1,
+ "left": 200,
+ "top": 150,
+ "right": 800,
+ "bottom": 850,
+ }
+ ]
+ }
+ ),
+ json.dumps({"page_index": 1, "edge": "left", "action": "expand", "amount": 300}),
+ json.dumps({"page_index": 1, "edge": "top", "action": "no_change", "amount": 0}),
+ json.dumps({"page_index": 1, "edge": "right", "action": "no_change", "amount": 0}),
+ json.dumps({"page_index": 1, "edge": "bottom", "action": "no_change", "amount": 0}),
+ ]
+ )
+ prompts: list[str | None] = []
+
+ async def _fake_complete_text(self, **kwargs: object) -> str: # noqa: ANN001
+ messages = cast("list[dict[str, Any]]", kwargs["messages"])
+ user_text_parts = _extract_user_text_parts(messages)
+ assert len(messages) == 1
+ assert user_text_parts
+ prompt = user_text_parts[0]
+ prompts.append(prompt)
+ return next(responses)
+
+ monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text)
+
+ detector = LLMPageDetector(model="gemini-2.5-flash", max_review_rounds=1)
+ candidates = await detector.detect(Image.new("RGB", (100, 100), color="white"))
+
+ assert len(candidates) == 1
+ assert candidates[0].bbox is not None
+ left, _, right, _ = candidates[0].bbox
+ assert left < 20.0
+ assert right > 70.0
+ assert prompts[0] == DEFAULT_BOUNDARY_DETECTION_PROMPT.strip()
+ assert prompts[1] is not None
+ assert prompts[1].startswith("You are an expert reviewer of a document page boundary annotation.")
+
+
+@pytest.mark.asyncio
+async def test_locate_text_block_bbox_with_llm_uses_block_prompt_transport(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ prompts: list[str | None] = []
+
+ async def _fake_complete_text(self, **kwargs: object) -> str: # noqa: ANN001
+ messages = cast("list[dict[str, Any]]", kwargs["messages"])
+ user_text_parts = _extract_user_text_parts(messages)
+ assert len(messages) == 1
+ assert user_text_parts
+ prompt = user_text_parts[0]
+ prompts.append(prompt)
+ return json.dumps(
+ {
+ "block_found": True,
+ "block": {"left": 180, "top": 260, "right": 860, "bottom": 720},
+ }
+ )
+
+ monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text)
+
+ bbox = await locate_text_block_bbox_with_llm(
+ Image.new("RGB", (120, 80), color="white"),
+ "Anno domini 1647\nActum Pragae",
+ block_tag="Paragraph",
+ model="gemini-2.5-flash",
+ )
+
+ assert bbox is not None
+ left, top, right, bottom = bbox
+ assert left < right
+ assert top < bottom
+ assert prompts[0] is not None
+ assert prompts[0].startswith("You are an expert reviewer of historical document layout.")
+ assert "Target block tag" in prompts[0]
+ assert "Paragraph" in prompts[0]
+
+
+@pytest.mark.asyncio
+async def test_locate_text_block_bbox_with_llm_accepts_shared_transport_instance(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_acompletion(**_: object) -> SimpleNamespace:
+ return SimpleNamespace(
+ choices=[
+ SimpleNamespace(
+ message=SimpleNamespace(
+ content=json.dumps(
+ {
+ "block_found": True,
+ "block": {"left": 180, "top": 260, "right": 860, "bottom": 720},
+ }
+ )
+ )
+ )
+ ],
+ _hidden_params={"response_cost": 0.25},
+ )
+
+ fake_module = ModuleType("litellm")
+ cast("Any", fake_module).acompletion = _fake_acompletion
+ cast("Any", fake_module).completion_cost = lambda **_: 999.0
+
+ monkeypatch.setitem(sys.modules, "litellm", fake_module)
+ monkeypatch.setattr("churro_ocr._internal.litellm._ensure_initialized", lambda: None)
+
+ transport = LiteLLMTransport()
+ bbox = await locate_text_block_bbox_with_llm(
+ Image.new("RGB", (120, 80), color="white"),
+ "Anno domini 1647\nActum Pragae",
+ block_tag="Paragraph",
+ model="example/model",
+ transport=transport,
+ )
+
+ assert bbox is not None
+ assert transport.total_cost_usd == pytest.approx(0.25)
+ assert transport.request_count == 1
+ assert transport.untracked_request_count == 0
+
+
+@pytest.mark.asyncio
+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
+ return json.dumps({"block_found": False, "block": None})
+
+ monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text)
+
+ bbox = await locate_text_block_bbox_with_llm(
+ Image.new("RGB", (120, 80), color="white"),
+ "missing paragraph",
+ block_tag="Paragraph",
+ model="gemini-2.5-flash",
+ )
+
+ assert bbox is None
+
+
+@pytest.mark.asyncio
+async def test_locate_text_block_bbox_with_llm_rejects_blank_text_and_tag() -> None:
+ image = Image.new("RGB", (120, 80), color="white")
+
+ with pytest.raises(ValueError, match="block_text must not be blank"):
+ await locate_text_block_bbox_with_llm(
+ image,
+ " ",
+ block_tag="Paragraph",
+ model="gemini-2.5-flash",
+ )
+
+ with pytest.raises(ValueError, match="block_tag must not be blank"):
+ await locate_text_block_bbox_with_llm(
+ image,
+ "Anno domini",
+ block_tag=" ",
+ model="gemini-2.5-flash",
+ )
+
+
+@pytest.mark.asyncio
+async def test_locate_text_block_bbox_with_llm_returns_none_when_review_pipeline_discards_box(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_complete_text_block_box(**_: object) -> _PageBox:
+ return _PageBox.from_json({"page_index": 1, "left": 200, "top": 200, "right": 800, "bottom": 800})
+
+ async def _fake_run_review_pipeline(**_: object) -> list[_PageBox]:
+ return []
+
+ monkeypatch.setattr(
+ "churro_ocr.providers.page_detection._complete_text_block_box",
+ _fake_complete_text_block_box,
+ )
+ monkeypatch.setattr(
+ "churro_ocr.providers.page_detection._run_review_pipeline",
+ _fake_run_review_pipeline,
+ )
+
+ bbox = await locate_text_block_bbox_with_llm(
+ Image.new("RGB", (100, 100), color="white"),
+ "Et fuit lux",
+ block_tag="Paragraph",
+ model="gemini-2.5-flash",
+ max_review_rounds=1,
+ )
+
+ assert bbox is None
+
+
+def test_locate_text_block_bbox_with_llm_sync_wraps_async_locator(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ async def _fake_locate(
+ image: Image.Image,
+ block_text: str,
+ *,
+ block_tag: str,
+ model: str,
+ transport: object | None = None,
+ max_review_rounds: int = 0,
+ ) -> tuple[float, float, float, float] | None:
+ del image, block_text, block_tag, model, transport, max_review_rounds
+ return (1.0, 2.0, 3.0, 4.0)
+
+ monkeypatch.setattr(
+ "churro_ocr.providers.page_detection.locate_text_block_bbox_with_llm",
+ _fake_locate,
+ )
+
+ assert locate_text_block_bbox_with_llm_sync(
+ Image.new("RGB", (20, 20), color="white"),
+ "Anno domini",
+ block_tag="Paragraph",
+ model="example/model",
+ ) == (1.0, 2.0, 3.0, 4.0)
+
+
+@pytest.mark.asyncio
+async def test_locate_text_block_bbox_with_llm_applies_iterative_review(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ responses = iter(
+ [
+ json.dumps(
+ {
+ "block_found": True,
+ "block": {
+ "left": 220,
+ "top": 300,
+ "right": 780,
+ "bottom": 760,
+ },
+ }
+ ),
+ json.dumps({"edge": "left", "action": "expand", "amount": 250}),
+ json.dumps({"edge": "top", "action": "no_change", "amount": 0}),
+ json.dumps({"edge": "right", "action": "no_change", "amount": 0}),
+ json.dumps({"edge": "bottom", "action": "no_change", "amount": 0}),
+ ]
+ )
+ prompts: list[str | None] = []
+
+ async def _fake_complete_text(self, **kwargs: object) -> str: # noqa: ANN001
+ messages = cast("list[dict[str, Any]]", kwargs["messages"])
+ user_text_parts = _extract_user_text_parts(messages)
+ assert len(messages) == 1
+ assert user_text_parts
+ prompt = user_text_parts[0]
+ prompts.append(prompt)
+ return next(responses)
+
+ monkeypatch.setattr("churro_ocr._internal.litellm.LiteLLMTransport.complete_text", _fake_complete_text)
+
+ bbox = await locate_text_block_bbox_with_llm(
+ Image.new("RGB", (100, 100), color="white"),
+ "Et fuit lux\nIn principio",
+ block_tag="Paragraph",
+ model="gemini-2.5-flash",
+ max_review_rounds=1,
+ )
+
+ assert bbox is not None
+ left, _, right, _ = bbox
+ assert left < 15.0
+ assert right > 65.0
+ assert prompts[0] is not None
+ assert "Target block tag" in prompts[0]
+ assert prompts[1] is not None
+ assert prompts[1].startswith("You are an expert reviewer of a content-block bounding box annotation.")
+
+
+def test_azure_page_detector_normalizes_page_polygon() -> None:
+ page = SimpleNamespace(
+ polygon=[0.0, 0.0, 50.0, 0.0, 50.0, 100.0, 0.0, 100.0],
+ width=50.0,
+ height=100.0,
+ )
+
+ polygon = _normalize_azure_page_polygon(page, image=Image.new("RGB", (200, 400), color="white"))
+
+ assert polygon == ((0.0, 0.0), (200.0, 0.0), (200.0, 400.0), (0.0, 400.0))
+
+
+@pytest.mark.asyncio
+async def test_azure_page_detector_detects_pages_and_closes_client(monkeypatch: pytest.MonkeyPatch) -> None:
+ calls = {"client_inits": 0, "requests": 0, "closes": 0}
+
+ class FakePoller:
+ async def result(self) -> SimpleNamespace:
+ return SimpleNamespace(
+ pages=[
+ SimpleNamespace(
+ polygon=[0.0, 0.0, 50.0, 0.0, 50.0, 100.0, 0.0, 100.0],
+ width=50.0,
+ height=100.0,
+ page_number=7,
+ unit="pixel",
+ angle=12.5,
+ ),
+ SimpleNamespace(
+ polygon=None,
+ ),
+ ]
+ )
+
+ class FakeClient:
+ def __init__(self, *, endpoint: str, credential: Any) -> None:
+ calls["client_inits"] += 1
+ assert endpoint == "https://example.test"
+ assert credential.key == "secret"
+
+ async def begin_analyze_document(
+ self,
+ *,
+ model_id: str,
+ body: Any,
+ content_type: str,
+ ) -> FakePoller:
+ calls["requests"] += 1
+ assert model_id == "prebuilt-layout"
+ assert content_type == "application/octet-stream"
+ assert body.read()
+ return FakePoller()
+
+ async def close(self) -> None:
+ calls["closes"] += 1
+
+ class FakeAzureKeyCredential:
+ def __init__(self, key: str) -> None:
+ self.key = key
+
+ azure_document_module = ModuleType("azure.ai.documentintelligence.aio")
+ cast(Any, azure_document_module).DocumentIntelligenceClient = FakeClient
+ azure_credentials_module = ModuleType("azure.core.credentials")
+ 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)
+
+ detector = AzurePageDetector(endpoint="https://example.test", api_key="secret")
+ candidates = await detector.detect(Image.new("RGB", (200, 400), color="white"))
+
+ assert len(candidates) == 2
+ assert candidates[0].polygon == ((0.0, 0.0), (200.0, 0.0), (200.0, 400.0), (0.0, 400.0))
+ assert candidates[0].bbox == (0.0, 0.0, 200.0, 400.0)
+ assert candidates[0].metadata == {
+ "page_index": 0,
+ "page_number": 7,
+ "detector": "azure",
+ "unit": "pixel",
+ "angle": 12.5,
+ }
+ assert candidates[1].bbox is None
+ assert candidates[1].polygon == ()
+ assert candidates[1].metadata == {
+ "page_index": 1,
+ "page_number": 2,
+ "detector": "azure",
+ }
+ assert calls == {"client_inits": 1, "requests": 1, "closes": 1}
+
+
+@pytest.mark.asyncio
+async def test_azure_page_detector_returns_full_image_when_service_returns_no_pages(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ calls = {"closes": 0}
+
+ class FakePoller:
+ async def result(self) -> SimpleNamespace:
+ return SimpleNamespace(pages=[])
+
+ class FakeClient:
+ def __init__(self, *, endpoint: str, credential: Any) -> None:
+ del endpoint, credential
+
+ async def begin_analyze_document(
+ self,
+ *,
+ model_id: str,
+ body: Any,
+ content_type: str,
+ ) -> FakePoller:
+ del model_id, body, content_type
+ return FakePoller()
+
+ async def close(self) -> None:
+ calls["closes"] += 1
+
+ class FakeAzureKeyCredential:
+ def __init__(self, key: str) -> None:
+ self.key = key
+
+ azure_document_module = ModuleType("azure.ai.documentintelligence.aio")
+ cast(Any, azure_document_module).DocumentIntelligenceClient = FakeClient
+ azure_credentials_module = ModuleType("azure.core.credentials")
+ 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)
+
+ image = Image.new("RGB", (120, 80), color="white")
+ detector = AzurePageDetector(endpoint="https://example.test", api_key="secret")
+ candidates = await detector.detect(image)
+
+ assert len(candidates) == 1
+ assert candidates[0].bbox == (0.0, 0.0, 120.0, 80.0)
+ assert candidates[0].polygon == ()
+ assert calls["closes"] == 1
+
+
+def test_azure_page_detector_type_is_public() -> None:
+ detector = AzurePageDetector(endpoint="https://example.test", api_key="secret")
+ assert detector.model_id == "prebuilt-layout"
+
+
+def test_build_ocr_backend_resolves_profile_defaults() -> None:
+ backend = cast(
+ "VLLMVisionOCRBackend",
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="vllm",
+ model="stanford-oval/churro-3B",
+ )
+ ),
+ )
+
+ assert backend.model_name == "churro-3B"
+ assert backend.template != DEFAULT_OCR_TEMPLATE
+
+
+def test_build_ocr_backend_uses_generic_defaults_for_qwen_model() -> None:
+ backend = cast(
+ "VLLMVisionOCRBackend",
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="vllm",
+ model="Qwen/Qwen3.5-0.8B",
+ )
+ ),
+ )
+
+ assert backend.model_name == "Qwen/Qwen3.5-0.8B"
+ assert backend.template == DEFAULT_OCR_TEMPLATE
+ assert backend.llm_kwargs == {}
+ assert backend.sampling_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS}
+
+
+def test_build_ocr_backend_merges_hf_overrides_with_profile_defaults() -> None:
+ backend = cast(
+ "HuggingFaceVisionOCRBackend",
+ build_ocr_backend(
+ OCRBackendSpec(
+ provider="hf",
+ model="kristaller486/dots.ocr-1.5",
+ options=HuggingFaceOptions(
+ model_kwargs={"torch_dtype": "auto"},
+ generation_kwargs={"temperature": 0.0},
+ ),
+ )
+ ),
+ )
+
+ assert backend.trust_remote_code is True
+ assert backend.processor_kwargs == {}
+ assert backend.model_kwargs["torch_dtype"] == "auto"
+ assert backend.generation_kwargs == {
+ "max_new_tokens": DEFAULT_OCR_MAX_TOKENS,
+ "temperature": 0.0,
+ }
diff --git a/tests/test_public_api_contracts.py b/tests/test_public_api_contracts.py
new file mode 100644
index 0000000..16ff2ad
--- /dev/null
+++ b/tests/test_public_api_contracts.py
@@ -0,0 +1,199 @@
+from __future__ import annotations
+
+import pytest
+from PIL import Image
+
+from churro_ocr.document import DocumentOCRResult
+from churro_ocr.ocr import OCRBackend, OCRClient, OCRResult
+from churro_ocr.page_detection import (
+ DocumentPage,
+ DocumentPageDetector,
+ PageCandidate,
+ PageDetectionRequest,
+ PageDetector,
+)
+
+
+class _MetadataEchoOCRBackend(OCRBackend):
+ async def ocr(self, page: DocumentPage) -> OCRResult:
+ return OCRResult(
+ text=f"{page.page_index}:{page.source_index}",
+ provider_name="echo",
+ model_name="echo-model",
+ metadata=dict(page.metadata),
+ )
+
+
+def test_document_page_properties_and_with_ocr() -> None:
+ page = DocumentPage.from_image(
+ Image.new("RGB", (14, 9), color="white"),
+ page_index=3,
+ source_index=2,
+ metadata={"kind": "original"},
+ )
+
+ result = page.with_ocr(
+ text="transcribed",
+ provider_name="fake",
+ model_name="fake-model",
+ ocr_metadata={"score": 0.9},
+ )
+
+ assert page.width == 14
+ assert page.height == 9
+ assert page.text is None
+ assert result.text == "transcribed"
+ assert result.provider_name == "fake"
+ assert result.model_name == "fake-model"
+ assert result.ocr_metadata == {"score": 0.9}
+ assert result.metadata == {"kind": "original"}
+
+
+def test_page_detector_defaults_to_full_image_when_no_backend() -> None:
+ image = Image.new("RGB", (20, 12), color="white")
+
+ pages = PageDetector().detect(PageDetectionRequest(image=image, trim_margin=0))
+
+ assert len(pages) == 1
+ assert pages[0].image.size == (20, 12)
+ assert pages[0].bbox == (0.0, 0.0, 20.0, 12.0)
+
+
+def test_page_detector_falls_back_to_full_image_when_backend_returns_no_candidates() -> None:
+ async def _empty_backend(_: Image.Image) -> list[PageCandidate]:
+ return []
+
+ pages = PageDetector(_empty_backend).detect(
+ PageDetectionRequest(image=Image.new("RGB", (18, 11), color="white"), trim_margin=0)
+ )
+
+ assert len(pages) == 1
+ assert pages[0].image.size == (18, 11)
+ assert pages[0].bbox == (0.0, 0.0, 18.0, 11.0)
+
+
+def test_page_detector_uses_candidate_image_directly() -> None:
+ async def _candidate_image(_: Image.Image) -> list[PageCandidate]:
+ return [PageCandidate(image=Image.new("RGB", (7, 6), color="black"))]
+
+ pages = PageDetector(_candidate_image).detect(
+ PageDetectionRequest(image=Image.new("RGB", (20, 20), color="white"))
+ )
+
+ assert len(pages) == 1
+ assert pages[0].image.size == (7, 6)
+
+
+def test_page_detector_uses_polygon_crop_and_masks_background() -> None:
+ async def _triangle(_: Image.Image) -> list[PageCandidate]:
+ return [PageCandidate(polygon=((5.0, 5.0), (15.0, 5.0), (10.0, 15.0)))]
+
+ pages = PageDetector(_triangle).detect(
+ PageDetectionRequest(image=Image.new("RGB", (20, 20), color="black"), trim_margin=0)
+ )
+
+ assert len(pages) == 1
+ assert pages[0].image.size == (10, 10)
+ assert pages[0].image.getpixel((5, 2)) == (0, 0, 0)
+ assert pages[0].image.getpixel((0, 9)) == (255, 255, 255)
+
+
+def test_page_detector_uses_source_copy_when_candidate_has_no_bbox_or_polygon() -> None:
+ source = Image.new("RGB", (17, 13), color="white")
+
+ async def _candidate_without_bounds(_: Image.Image) -> list[PageCandidate]:
+ return [PageCandidate(metadata={"kind": "full-copy"})]
+
+ pages = PageDetector(_candidate_without_bounds).detect(PageDetectionRequest(image=source, trim_margin=0))
+
+ assert len(pages) == 1
+ assert pages[0].image.size == source.size
+ assert pages[0].metadata == {"kind": "full-copy"}
+ assert pages[0].image is not source
+
+
+def test_document_page_detector_detect_image_sync_returns_page_detection_result() -> None:
+ image = Image.new("RGB", (21, 10), color="white")
+
+ result = DocumentPageDetector().detect_image_sync(PageDetectionRequest(image=image, trim_margin=0))
+
+ assert result.source_type == "image"
+ assert len(result.pages) == 1
+ assert result.pages[0].image.size == (21, 10)
+
+
+@pytest.mark.asyncio
+async def test_document_page_detector_detect_pdf_async_preserves_source_indexes(
+ monkeypatch: pytest.MonkeyPatch,
+) -> 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"),
+ ],
+ )
+
+ result = await DocumentPageDetector().detect_pdf("sample.pdf", dpi=144, trim_margin=0)
+
+ assert result.source_type == "pdf"
+ assert result.metadata == {"dpi": 144, "path": "sample.pdf"}
+ assert [page.source_index for page in result.pages] == [0, 1]
+ assert [page.page_index for page in result.pages] == [0, 1]
+
+
+def test_ocr_client_ocr_image_propagates_metadata_and_indexes() -> None:
+ client = OCRClient(_MetadataEchoOCRBackend())
+
+ page = client.ocr_image(
+ image=Image.new("RGB", (9, 7), color="white"),
+ page_index=4,
+ source_index=3,
+ metadata={"source": "direct"},
+ )
+
+ assert page.page_index == 4
+ assert page.source_index == 3
+ assert page.text == "4:3"
+ assert page.provider_name == "echo"
+ assert page.model_name == "echo-model"
+ assert page.ocr_metadata == {"source": "direct"}
+
+
+@pytest.mark.asyncio
+async def test_ocr_client_aocr_image_from_path_propagates_metadata_and_indexes(write_image_file) -> None:
+ image_path = write_image_file(size=(9, 7))
+ page = await OCRClient(_MetadataEchoOCRBackend()).aocr_image(
+ image_path=image_path,
+ page_index=6,
+ source_index=5,
+ metadata={"source": "path"},
+ )
+
+ assert page.page_index == 6
+ assert page.source_index == 5
+ assert page.text == "6:5"
+ assert page.ocr_metadata == {"source": "path"}
+
+
+def test_document_ocr_result_as_ocr_results_preserves_metadata_and_order() -> None:
+ first = DocumentPage.from_image(Image.new("RGB", (4, 4), color="white"), metadata={"slot": 1}).with_ocr(
+ text="one",
+ provider_name="provider-a",
+ model_name="model-a",
+ ocr_metadata={"cost": 0.1},
+ )
+ second = DocumentPage.from_image(Image.new("RGB", (4, 4), color="white"), metadata={"slot": 2}).with_ocr(
+ text="two",
+ provider_name="provider-b",
+ model_name="model-b",
+ ocr_metadata={"cost": 0.2},
+ )
+ result = DocumentOCRResult(pages=[first, second], source_type="image", metadata={"path": "scan.png"})
+
+ ocr_results = result.as_ocr_results()
+
+ assert result.texts() == ["one", "two"]
+ assert [ocr_result.text for ocr_result in ocr_results] == ["one", "two"]
+ assert [ocr_result.provider_name for ocr_result in ocr_results] == ["provider-a", "provider-b"]
+ assert [ocr_result.metadata for ocr_result in ocr_results] == [{"cost": 0.1}, {"cost": 0.2}]
diff --git a/tests/test_settings.py b/tests/test_settings.py
deleted file mode 100644
index 4fdeeca..0000000
--- a/tests/test_settings.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""Tests for the centralised configuration loader."""
-
-from __future__ import annotations
-
-from pathlib import Path
-
-import pytest
-
-from churro.config.settings import get_settings
-
-
-def _write_env(path: Path, content: str) -> None:
- lines = [line.rstrip() for line in content.strip().splitlines()]
- path.write_text("\n".join(lines) + "\n", encoding="utf-8")
-
-
-def test_env_file_values_are_loaded(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
- """Ensure values from a dedicated env file are parsed into the snapshot."""
- # Remove ambient environment variables that could override the file.
- for key in (
- "AZURE_API_BASE",
- "AZURE_API_VERSION",
- "AZURE_OPENAI_API_KEY",
- "LOCAL_VLLM_PORT",
- "VERTEX_AI_LOCATION",
- "DOCUMENT_VERTEX_AI_LOCATION",
- ):
- monkeypatch.delenv(key, raising=False)
-
- env_file = tmp_path / "test.env"
- _write_env(
- env_file,
- """
- AZURE_API_BASE=https://azure.example.com/
- AZURE_API_VERSION=2025-04-01-preview
- AZURE_OPENAI_API_KEY=abc123
- LOCAL_VLLM_PORT=12345
- VERTEX_AI_LOCATION=us-central1
- DOCUMENT_VERTEX_AI_LOCATION=us
- """,
- )
- # Clear any existing snapshot for this env file.
- settings = get_settings(env_file=env_file, reload=True)
-
- assert settings.env_file == env_file.resolve()
- assert settings.azure_openai.api_base == "https://azure.example.com/"
- assert settings.azure_openai.api_version == "2025-04-01-preview"
- assert settings.azure_openai.api_key == "abc123"
- assert settings.local.vllm_port == 12345
- assert settings.vertex_ai.location == "us-central1"
- assert settings.vertex_ai.document_ai_location == "us"
-
-
-def test_environment_variables_override_env_file(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-) -> None:
- """Existing environment variables should take precedence over .env contents."""
- env_file = tmp_path / "override.env"
- _write_env(
- env_file,
- """
- AZURE_API_VERSION=2024-01-01-preview
- LOCAL_VLLM_PORT=23456
- """,
- )
- monkeypatch.setenv("AZURE_API_VERSION", "2026-02-02-stable")
- monkeypatch.setenv("LOCAL_VLLM_PORT", "34567")
-
- settings = get_settings(env_file=env_file, reload=True)
-
- assert settings.azure_openai.api_version == "2026-02-02-stable"
- assert settings.local.vllm_port == 34567
-
-
-def test_reload_picks_up_changes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
- """Calling get_settings with reload=True should refresh cached values."""
- env_file = tmp_path / "reload.env"
- monkeypatch.delenv("AZURE_API_VERSION", raising=False)
- _write_env(
- env_file,
- """
- AZURE_API_VERSION=2025-01-01
- """,
- )
- settings = get_settings(env_file=env_file, reload=True)
- assert settings.azure_openai.api_version == "2025-01-01"
-
- monkeypatch.setenv("AZURE_API_VERSION", "2030-05-05")
- updated = get_settings(env_file=env_file, reload=True)
- assert updated.azure_openai.api_version == "2030-05-05"
diff --git a/tests/test_tooling_benchmark.py b/tests/test_tooling_benchmark.py
new file mode 100644
index 0000000..e672903
--- /dev/null
+++ b/tests/test_tooling_benchmark.py
@@ -0,0 +1,758 @@
+from __future__ import annotations
+
+import asyncio
+from pathlib import Path
+from typing import cast
+
+import pytest
+from datasets import Dataset
+from PIL import Image
+
+from churro_ocr.providers.hf import HuggingFaceVisionOCRBackend
+from churro_ocr.providers.ocr import LiteLLMVisionOCRBackend
+from churro_ocr.providers.specs import DEFAULT_OCR_MAX_TOKENS
+from churro_ocr.providers.vllm import VLLMVisionOCRBackend
+from churro_ocr.templates import CHURRO_3B_XML_TEMPLATE
+from tooling.benchmarking import benchmark
+from tooling.evaluation.types import BenchmarkDatasetExample
+
+
+def _benchmark_example(
+ example_id: str,
+ *,
+ size: tuple[int, int] = (8, 8),
+ transcription: str = "",
+ dataset_id: str | None = None,
+ document_type: str = "print",
+ main_language: str = "English",
+ main_script: str = "Latin",
+) -> BenchmarkDatasetExample:
+ return {
+ "image": Image.new("RGB", size, color="white"),
+ "cleaned_transcription": 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,
+ }
+
+
+@pytest.mark.parametrize(
+ "removed_args",
+ [
+ ["--use-page-detection"],
+ ["--page-detector", "azure"],
+ ["--trim-margin", "30"],
+ ["--dpi", "300"],
+ ],
+)
+def test_parse_args_rejects_removed_page_detection_options(removed_args: list[str]) -> None:
+ with pytest.raises(SystemExit):
+ benchmark.parse_args(
+ [
+ "--backend",
+ "azure",
+ "--dataset-split",
+ "dev",
+ "--endpoint",
+ "https://example.invalid",
+ "--api-key",
+ "secret",
+ *removed_args,
+ ]
+ )
+
+
+@pytest.mark.parametrize(
+ "removed_args",
+ [
+ ["--system-prompt", "system"],
+ ["--prompt", "custom prompt"],
+ ],
+)
+def test_parse_args_rejects_removed_prompt_override_options(removed_args: list[str]) -> None:
+ with pytest.raises(SystemExit):
+ benchmark.parse_args(
+ [
+ "--backend",
+ "hf",
+ "--dataset-split",
+ "dev",
+ "--model",
+ "example/model",
+ *removed_args,
+ ]
+ )
+
+
+def test_validate_options_requires_model_for_litellm() -> None:
+ options = benchmark.BenchmarkOptions(
+ backend="litellm",
+ model=None,
+ dataset_split="dev",
+ )
+ assert benchmark._validate_options(options) == 1
+
+
+def test_validate_options_requires_model_for_hf() -> None:
+ options = benchmark.BenchmarkOptions(
+ backend="hf",
+ model=None,
+ dataset_split="dev",
+ )
+ assert benchmark._validate_options(options) == 1
+
+
+def test_validate_options_requires_model_for_vllm() -> None:
+ options = benchmark.BenchmarkOptions(
+ backend="vllm",
+ model=None,
+ dataset_split="dev",
+ )
+ assert benchmark._validate_options(options) == 1
+
+
+def test_validate_options_rejects_invalid_vllm_gpu_memory_utilization() -> None:
+ options = benchmark.BenchmarkOptions(
+ backend="vllm",
+ model="Qwen/Qwen3.5-0.8B",
+ dataset_split="dev",
+ vllm_gpu_memory_utilization=1.5,
+ )
+ assert benchmark._validate_options(options) == 1
+
+
+def test_validate_options_rejects_invalid_split() -> None:
+ options = benchmark.BenchmarkOptions(
+ backend="azure",
+ dataset_split="train",
+ endpoint="https://example.invalid",
+ api_key="secret",
+ )
+ assert benchmark._validate_options(options) == 1
+
+
+def test_parse_args_accepts_subset_filters() -> None:
+ options = benchmark.parse_args(
+ [
+ "--backend",
+ "azure",
+ "--dataset-split",
+ "dev",
+ "--endpoint",
+ "https://example.invalid",
+ "--api-key",
+ "secret",
+ "--language",
+ "Chinese",
+ "--document-type",
+ "print",
+ ]
+ )
+
+ assert options.language == "Chinese"
+ assert options.document_type == "print"
+
+
+def test_parse_args_accepts_vllm_resource_overrides() -> None:
+ options = benchmark.parse_args(
+ [
+ "--backend",
+ "vllm",
+ "--dataset-split",
+ "dev",
+ "--model",
+ "Qwen/Qwen3.5-0.8B",
+ "--vllm-gpu-memory-utilization",
+ "0.25",
+ "--vllm-cpu-offload-gb",
+ "8",
+ ]
+ )
+
+ assert options.vllm_gpu_memory_utilization == pytest.approx(0.25)
+ assert options.vllm_cpu_offload_gb == pytest.approx(8.0)
+
+
+def test_build_ocr_backend_enables_disk_cache_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-4.1-mini",
+ )
+ ),
+ )
+
+ assert backend.transport.config.cache_dir == cache_dir
+ assert backend.transport.config.completion_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS}
+
+
+def test_build_ocr_backend_uses_dots_preset_for_hf() -> None:
+ backend = cast(
+ "HuggingFaceVisionOCRBackend",
+ benchmark._build_ocr_backend(
+ benchmark.BenchmarkOptions(
+ backend="hf",
+ dataset_split="dev",
+ model="kristaller486/dots.ocr-1.5",
+ )
+ ),
+ )
+
+ assert backend.model_name == "dots.ocr-1.5"
+ assert backend.processor_kwargs == {}
+ assert backend.trust_remote_code is True
+ assert backend.model_kwargs["dtype"] in {"auto", "float32"}
+ if backend.model_kwargs["dtype"] == "auto":
+ assert backend.model_kwargs["device_map"] == "auto"
+ assert "max_memory" in backend.model_kwargs
+ assert backend.generation_kwargs == {"max_new_tokens": DEFAULT_OCR_MAX_TOKENS}
+
+
+def test_build_ocr_backend_uses_dots_preset_for_vllm() -> None:
+ backend = cast(
+ "VLLMVisionOCRBackend",
+ benchmark._build_ocr_backend(
+ benchmark.BenchmarkOptions(
+ backend="vllm",
+ dataset_split="dev",
+ model="kristaller486/dots.ocr-1.5",
+ )
+ ),
+ )
+
+ assert backend.model_name == "dots.ocr-1.5"
+ assert backend.processor_kwargs == {}
+ assert backend.trust_remote_code is True
+ assert backend.sampling_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS}
+
+
+def test_build_ocr_backend_uses_churro_preset_template_for_vllm() -> None:
+ backend = cast(
+ "VLLMVisionOCRBackend",
+ benchmark._build_ocr_backend(
+ benchmark.BenchmarkOptions(
+ backend="vllm",
+ dataset_split="dev",
+ model="stanford-oval/churro-3B",
+ )
+ ),
+ )
+
+ assert backend.template == CHURRO_3B_XML_TEMPLATE
+ assert backend.model_name == "churro-3B"
+
+
+def test_build_ocr_backend_uses_generic_qwen_model_name_for_vllm() -> None:
+ backend = cast(
+ "VLLMVisionOCRBackend",
+ benchmark._build_ocr_backend(
+ benchmark.BenchmarkOptions(
+ backend="vllm",
+ dataset_split="dev",
+ model="Qwen/Qwen3.5-0.8B",
+ vllm_gpu_memory_utilization=0.25,
+ vllm_cpu_offload_gb=8.0,
+ )
+ ),
+ )
+
+ assert backend.model_name == "Qwen/Qwen3.5-0.8B"
+ assert backend.llm_kwargs == {
+ "gpu_memory_utilization": 0.25,
+ "cpu_offload_gb": 8.0,
+ }
+ assert backend.sampling_kwargs == {"max_tokens": DEFAULT_OCR_MAX_TOKENS}
+
+
+def test_build_ocr_backend_aligns_hf_and_vllm_templates_for_generic_models() -> None:
+ hf_backend = cast(
+ "HuggingFaceVisionOCRBackend",
+ benchmark._build_ocr_backend(
+ benchmark.BenchmarkOptions(
+ backend="hf",
+ dataset_split="dev",
+ model="example/model",
+ )
+ ),
+ )
+ vllm_backend = cast(
+ "VLLMVisionOCRBackend",
+ benchmark._build_ocr_backend(
+ benchmark.BenchmarkOptions(
+ backend="vllm",
+ dataset_split="dev",
+ model="example/model",
+ )
+ ),
+ )
+
+ assert hf_backend.template == vllm_backend.template
+
+
+@pytest.mark.asyncio
+async def test_run_executes_pipeline(monkeypatch, tmp_path: Path) -> None:
+ dataset: list[BenchmarkDatasetExample] = [
+ _benchmark_example("0", transcription="first"),
+ _benchmark_example(
+ "1",
+ transcription="second",
+ dataset_id="dataset-1",
+ document_type="handwriting",
+ main_language="Persian",
+ main_script="Arabic",
+ ),
+ _benchmark_example(
+ "2",
+ transcription="third",
+ dataset_id="dataset-2",
+ main_language="French",
+ ),
+ ]
+
+ def fake_load_dataset(dataset_id: str, *, split: str): # noqa: ANN001
+ 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
+ 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])], ["prediction"]
+
+ monkeypatch.setattr(benchmark, "_predict_texts", fake_predict)
+
+ captured: dict[str, object] = {}
+
+ def fake_compute_metrics(ds, predictions, output_prefix, elapsed_time): # noqa: ANN001
+ captured["dataset"] = ds
+ captured["predictions"] = predictions
+ captured["output_prefix"] = output_prefix
+ captured["elapsed_time"] = elapsed_time
+ return {"status": "ok"}
+
+ monkeypatch.setattr(benchmark, "compute_metrics", fake_compute_metrics)
+ time_values = iter([10.0, 13.5])
+ monkeypatch.setattr(benchmark, "time", lambda: next(time_values))
+
+ options = benchmark.BenchmarkOptions(
+ backend="azure",
+ dataset_split="dev",
+ endpoint="https://example.invalid",
+ api_key="secret",
+ max_concurrency=2,
+ input_size=1,
+ offset=1,
+ output_dir=tmp_path / "outputs",
+ )
+
+ result = await benchmark.run(options)
+
+ assert result == 0
+ assert captured["dataset"] == [benchmark._build_evaluation_example(dataset[1])]
+ assert captured["predictions"] == ["prediction"]
+ assert captured["output_prefix"] == str(tmp_path / "outputs")
+ assert captured["elapsed_time"] == pytest.approx(3.5)
+
+
+def test_create_output_prefix_includes_subset_filters(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ monkeypatch.setattr(benchmark, "__file__", str(tmp_path / "tooling" / "benchmarking" / "benchmark.py"))
+
+ output_prefix = benchmark.create_output_prefix(
+ benchmark.BenchmarkOptions(
+ backend="azure",
+ dataset_split="dev",
+ endpoint="https://example.invalid",
+ api_key="secret",
+ language="Chinese Simplified",
+ document_type="print",
+ )
+ )
+
+ assert output_prefix.endswith("dev/azure_language_chinese_simplified_document_type_print")
+
+
+def test_selected_dataset_examples_preserves_slice_without_materializing() -> None:
+ dataset_stream = (cast("BenchmarkDatasetExample", {"example_id": str(index)}) for index in range(5))
+
+ selected = benchmark._selected_dataset_examples(
+ dataset_stream,
+ benchmark.BenchmarkOptions(
+ backend="azure",
+ dataset_split="dev",
+ endpoint="https://example.invalid",
+ api_key="secret",
+ input_size=2,
+ offset=1,
+ ),
+ )
+
+ assert list(selected) == [{"example_id": "1"}, {"example_id": "2"}]
+
+
+def test_selected_dataset_examples_filters_before_offset_and_input_size() -> None:
+ dataset_stream = iter(
+ [
+ cast(
+ "BenchmarkDatasetExample",
+ {"example_id": "0", "main_language": "English", "document_type": "print"},
+ ),
+ cast(
+ "BenchmarkDatasetExample",
+ {"example_id": "1", "main_language": "Chinese", "document_type": "handwriting"},
+ ),
+ cast(
+ "BenchmarkDatasetExample",
+ {"example_id": "2", "main_language": "Chinese", "document_type": "print"},
+ ),
+ cast(
+ "BenchmarkDatasetExample",
+ {"example_id": "3", "main_language": "Chinese", "document_type": "print"},
+ ),
+ cast(
+ "BenchmarkDatasetExample",
+ {"example_id": "4", "main_language": "French", "document_type": "print"},
+ ),
+ ]
+ )
+
+ selected = benchmark._selected_dataset_examples(
+ dataset_stream,
+ benchmark.BenchmarkOptions(
+ backend="azure",
+ dataset_split="dev",
+ endpoint="https://example.invalid",
+ api_key="secret",
+ language=" chinese ",
+ document_type="PRINT",
+ input_size=1,
+ offset=1,
+ ),
+ )
+
+ assert list(selected) == [{"example_id": "3", "main_language": "Chinese", "document_type": "print"}]
+
+
+def test_selected_dataset_examples_filters_materialized_dataset() -> None:
+ dataset = Dataset.from_list(
+ [
+ {"example_id": "0", "main_language": "English", "document_type": "print"},
+ {"example_id": "1", "main_language": "Chinese", "document_type": "handwriting"},
+ {"example_id": "2", "main_language": "Chinese", "document_type": "print"},
+ {"example_id": "3", "main_language": "Chinese", "document_type": "print"},
+ ]
+ )
+
+ selected = benchmark._selected_dataset_examples(
+ dataset,
+ benchmark.BenchmarkOptions(
+ backend="azure",
+ dataset_split="dev",
+ endpoint="https://example.invalid",
+ api_key="secret",
+ language=" chinese ",
+ document_type="PRINT",
+ input_size=1,
+ offset=1,
+ ),
+ )
+ selected_dataset = cast("Dataset", selected)
+
+ assert selected_dataset.num_rows == 1
+ assert selected_dataset[0] == {
+ "example_id": "3",
+ "main_language": "Chinese",
+ "document_type": "print",
+ }
+
+
+@pytest.mark.asyncio
+async def test_predict_texts_updates_progress_and_preserves_order(monkeypatch) -> None:
+ dataset: list[BenchmarkDatasetExample] = [
+ _benchmark_example("0", size=(3, 3), transcription="alpha"),
+ _benchmark_example(
+ "1",
+ size=(1, 1),
+ transcription="beta",
+ dataset_id="dataset-1",
+ document_type="handwriting",
+ main_language="Persian",
+ main_script="Arabic",
+ ),
+ _benchmark_example(
+ "2",
+ size=(2, 2),
+ transcription="gamma",
+ dataset_id="dataset-2",
+ main_language="French",
+ ),
+ ]
+
+ class FakeProgressBar:
+ def __init__(self, *, total: int | None, desc: str, unit: str) -> None:
+ self.total = total
+ self.desc = desc
+ self.unit = unit
+ self.updates: list[int] = []
+ self.postfixes: list[dict[str, int]] = []
+ self.refresh_count = 0
+
+ def __enter__(self) -> FakeProgressBar:
+ return self
+
+ def __exit__(self, _exc_type, _exc, _tb) -> None: # noqa: ANN001
+ return None
+
+ def update(self, amount: int) -> None:
+ self.updates.append(amount)
+
+ def set_postfix(self, *, submitted: int, in_flight: int, refresh: bool) -> None:
+ self.postfixes.append(
+ {
+ "submitted": submitted,
+ "in_flight": in_flight,
+ "refresh": int(refresh),
+ }
+ )
+
+ def refresh(self) -> None:
+ self.refresh_count += 1
+
+ progress_bars: list[FakeProgressBar] = []
+
+ def fake_tqdm(*, total: int | None, desc: str, unit: str) -> FakeProgressBar:
+ progress_bar = FakeProgressBar(total=total, desc=desc, unit=unit)
+ progress_bars.append(progress_bar)
+ return progress_bar
+
+ class FakeOCRResult:
+ def __init__(self, text: str) -> None:
+ self.text = text
+
+ class FakeOCRBackend:
+ async def ocr(self, page): # noqa: ANN001
+ await asyncio.sleep(page.width / 1000)
+ return FakeOCRResult(text=f"page-{page.width}")
+
+ monkeypatch.setattr(benchmark, "tqdm", fake_tqdm)
+ 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 predictions == ["page-3", "page-1", "page-2"]
+ assert evaluation_examples == [benchmark._build_evaluation_example(example) for example in dataset]
+ assert len(progress_bars) == 1
+ assert progress_bars[0].total == 3
+ assert progress_bars[0].desc == "OCR"
+ assert progress_bars[0].unit == "page"
+ assert progress_bars[0].updates == [1, 1, 1]
+ assert progress_bars[0].postfixes[-1] == {
+ "submitted": 3,
+ "in_flight": 0,
+ "refresh": 0,
+ }
+ assert progress_bars[0].refresh_count >= 1
+
+
+@pytest.mark.asyncio
+async def test_predict_texts_uses_batch_backend_with_max_concurrency_as_batch_size(monkeypatch) -> None:
+ dataset: list[BenchmarkDatasetExample] = [
+ _benchmark_example("0", size=(3, 3), transcription="alpha"),
+ _benchmark_example(
+ "1",
+ size=(1, 1),
+ transcription="beta",
+ dataset_id="dataset-1",
+ document_type="handwriting",
+ main_language="Persian",
+ main_script="Arabic",
+ ),
+ _benchmark_example(
+ "2",
+ size=(2, 2),
+ transcription="gamma",
+ dataset_id="dataset-2",
+ main_language="French",
+ ),
+ ]
+ captured_batch_sizes: list[int] = []
+
+ class FakeOCRResult:
+ def __init__(self, text: str) -> None:
+ self.text = text
+
+ class FakeBatchBackend:
+ async def ocr_batch(self, pages): # noqa: ANN001
+ captured_batch_sizes.append(len(pages))
+ return [FakeOCRResult(text=f"page-{page.width}") for page in pages]
+
+ 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 captured_batch_sizes == [2, 1]
+ assert predictions == ["page-3", "page-1", "page-2"]
+ assert evaluation_examples == [benchmark._build_evaluation_example(example) for example in dataset]
+
+
+@pytest.mark.asyncio
+async def test_predict_texts_logs_first_batch_output_once(monkeypatch) -> None:
+ dataset = [
+ _benchmark_example("0", size=(3, 3), transcription="alpha"),
+ _benchmark_example("1", size=(1, 1), transcription="beta"),
+ ]
+ logged_messages: list[str] = []
+
+ class FakeLogger:
+ def info(self, message: str, *args: object) -> None:
+ logged_messages.append(message % args if args else message)
+
+ class FakeOCRResult:
+ def __init__(self, text: str) -> None:
+ self.text = text
+
+ class FakeBatchBackend:
+ async def ocr_batch(self, pages): # noqa: ANN001
+ return [FakeOCRResult(text=f"page-{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=2,
+ )
+
+ assert predictions == ["page-3", "page-1"]
+ assert logged_messages == [
+ "First benchmark OCR output for backend=hf model=kristaller486/dots.ocr-1.5:\npage-3"
+ ]
+
+
+@pytest.mark.asyncio
+async def test_predict_texts_uses_max_concurrency_for_vllm_batch_backend(monkeypatch) -> None:
+ dataset = [
+ _benchmark_example(str(index), size=(index + 1, index + 1), transcription=f"text-{index}")
+ for index in range(10)
+ ]
+ captured_batch_sizes: list[int] = []
+
+ class FakeOCRResult:
+ def __init__(self, text: str) -> None:
+ self.text = text
+
+ class FakeBatchBackend:
+ async def ocr_batch(self, pages): # noqa: ANN001
+ captured_batch_sizes.append(len(pages))
+ return [FakeOCRResult(text=f"page-{page.width}") for page in pages]
+
+ monkeypatch.setattr(benchmark, "_build_ocr_backend", lambda _: FakeBatchBackend())
+
+ options = benchmark.BenchmarkOptions(
+ backend="vllm",
+ dataset_split="dev",
+ model="Qwen/Qwen3.5-0.8B",
+ max_concurrency=2,
+ )
+
+ evaluation_examples, predictions = await benchmark._predict_texts(
+ dataset,
+ options,
+ total_pages=10,
+ )
+
+ assert captured_batch_sizes == [2, 2, 2, 2, 2]
+ assert predictions == [f"page-{index + 1}" for index in range(10)]
+ assert evaluation_examples == [benchmark._build_evaluation_example(example) for example in dataset]
+
+
+@pytest.mark.asyncio
+async def test_predict_texts_logs_first_submitted_output_once_for_non_batch_backend(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:
+ logged_messages.append(message % args if args else message)
+
+ class FakeOCRResult:
+ def __init__(self, text: str) -> None:
+ self.text = text
+
+ class FakeOCRBackend:
+ async def ocr(self, page): # noqa: ANN001
+ await asyncio.sleep(page.width / 1000)
+ return FakeOCRResult(text=f"page-{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 predictions == ["page-3", "page-1", "page-2"]
+ assert logged_messages == ["First benchmark OCR output for backend=azure model=:\npage-3"]
diff --git a/tests/test_tooling_evaluate_page.py b/tests/test_tooling_evaluate_page.py
new file mode 100644
index 0000000..1963af4
--- /dev/null
+++ b/tests/test_tooling_evaluate_page.py
@@ -0,0 +1,99 @@
+from __future__ import annotations
+
+import importlib
+from types import SimpleNamespace
+
+import pytest
+
+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",
+ }
+
+ monkeypatch.setattr(
+ evaluate_page_module,
+ "calculate_metrics",
+ lambda _: {"normalized_levenshtein_similarity": 1.0},
+ )
+
+ result = evaluate_page_module.evaluate_page((example, "predicted"))
+
+ assert result["example_id"] == "ahisto/1069_69"
+ assert result["gold_text"] == "clean "
+ assert result["predicted_text"] == "predicted"
+ assert result["main_language"] == "Czech"
+ assert result["main_script"] == "Latin"
+ assert result["document_type"] == "print"
+ assert result["dataset_id"] == "ahisto"
+
+
+def test_calculate_metrics_uses_cleaned_transcription(monkeypatch) -> None:
+ captured: dict[str, str] = {}
+
+ def fake_core(predicted_text: str, gold_text: str, language: str, script: str) -> dict[str, object]:
+ captured["predicted_text"] = predicted_text
+ captured["gold_text"] = gold_text
+ captured["language"] = language
+ captured["script"] = script
+ return {"normalized_levenshtein_similarity": 1.0}
+
+ 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",
+ }
+
+ result = evaluate_page_module.calculate_metrics((example, "pred"))
+
+ assert captured == {
+ "predicted_text": "pred",
+ "gold_text": "new",
+ "language": "Czech",
+ "script": "Latin",
+ }
+ assert result["normalized_levenshtein_similarity"] == 1.0
+
+
+@pytest.mark.parametrize(
+ ("predicted_text", "expected"),
+ [
+ (" Pred", "pred"),
+ ("Pred ", "pred"),
+ ("\nPred\n ", "pred"),
+ ],
+)
+def test_calculate_metrics_strips_output_tags_before_normalization(
+ monkeypatch,
+ predicted_text: str,
+ expected: str,
+) -> None:
+ monkeypatch.setattr(evaluate_page_module, "initialize_metrics", lambda: None)
+ monkeypatch.setattr(
+ evaluate_page_module,
+ "bleu_metric",
+ SimpleNamespace(compute=lambda *_args, **_kwargs: {"bleu": 0.0}),
+ )
+
+ example: MetricInputExample = {
+ "example_id": "ahisto/1069_69",
+ "cleaned_transcription": "gold",
+ "main_language": "Czech",
+ "main_script": "Latin",
+ }
+
+ result = evaluate_page_module.calculate_metrics((example, predicted_text))
+
+ assert result["normalized_predicted_text"] == expected
diff --git a/tests/test_evaluation_metrics_unit.py b/tests/test_tooling_metrics.py
similarity index 53%
rename from tests/test_evaluation_metrics_unit.py
rename to tests/test_tooling_metrics.py
index f0b48c3..f328c13 100644
--- a/tests/test_evaluation_metrics_unit.py
+++ b/tests/test_tooling_metrics.py
@@ -2,36 +2,37 @@
import json
from pathlib import Path
+from typing import cast
-import pytest
-
-from churro.evaluation import metrics
+from tooling.evaluation import metrics
+from tooling.evaluation.types import EvaluationExample, PageEvaluationResult
def test_calculate_language_and_type_metrics_handles_missing_categories() -> None:
outputs = [
- {
- "main_language": "english",
- "document_type": "print",
- "normalized_levenshtein_similarity": 0.8,
- },
- {
- "main_language": "spanish",
- "document_type": "handwriting",
- "normalized_levenshtein_similarity": 0.6,
- },
+ cast(
+ "PageEvaluationResult",
+ {
+ "main_language": "english",
+ "document_type": "print",
+ "normalized_levenshtein_similarity": 0.8,
+ },
+ ),
+ cast(
+ "PageEvaluationResult",
+ {
+ "main_language": "spanish",
+ "document_type": "handwriting",
+ "normalized_levenshtein_similarity": 0.6,
+ },
+ ),
]
- language_metrics, type_metrics, combined_metrics = metrics.calculate_language_and_type_metrics(
- outputs
- )
+ language_metrics, type_metrics, combined_metrics = metrics.calculate_language_and_type_metrics(outputs)
assert language_metrics == {"english": 0.8, "spanish": 0.6}
assert type_metrics == {"print": 0.8, "handwriting": 0.6}
- assert combined_metrics == {
- "english_print": 0.8,
- "spanish_handwriting": 0.6,
- }
+ assert combined_metrics == {"english_print": 0.8, "spanish_handwriting": 0.6}
def test_to_rounded_percentage_preserves_non_numeric_values() -> None:
@@ -41,50 +42,54 @@ def test_to_rounded_percentage_preserves_non_numeric_values() -> None:
def test_compute_metrics_writes_expected_outputs(
- tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+ tmp_path: Path,
+ monkeypatch,
) -> None:
- dataset = [
- {"file_name": "file1", "main_language": "english", "document_type": "print"},
+ dataset: list[EvaluationExample] = [
+ {
+ "example_id": "file1",
+ "cleaned_transcription": "",
+ "dataset_id": "ahisto",
+ "main_language": "english",
+ "main_script": "Latin",
+ "document_type": "print",
+ },
]
- predicted_texts = [""] # exercise empty-text correction
+ predicted_texts = [""]
- def fake_batch_evaluate(
- ds: list[dict[str, str]],
- preds: list[str],
- ) -> tuple[
- dict[str, float],
- list[dict[str, float | str]],
- ]:
+ def fake_batch_evaluate(ds, preds): # noqa: ANN001
+ assert ds == dataset
assert preds == [""]
return (
{"normalized_levenshtein_similarity": 0.9},
[
- {
- "main_language": "english",
- "document_type": "print",
- "normalized_levenshtein_similarity": 0.9,
- }
+ cast(
+ "PageEvaluationResult",
+ {
+ "main_language": "english",
+ "document_type": "print",
+ "normalized_levenshtein_similarity": 0.9,
+ },
+ )
],
)
monkeypatch.setattr(metrics, "batch_evaluate", fake_batch_evaluate)
- monkeypatch.setattr(metrics, "get_llm_total_cost", lambda: 1.234)
- monkeypatch.setattr(metrics, "get_total_azure_cost", lambda: 2.345)
+ monkeypatch.setattr(metrics, "_get_llm_total_cost", lambda: 1.234)
+ monkeypatch.setattr(metrics, "_get_azure_total_cost", lambda: 2.345)
output_prefix = tmp_path / "results"
- output_prefix.mkdir()
-
combined = metrics.compute_metrics(
dataset=dataset,
predicted_texts=predicted_texts,
- output_prefix=str(output_prefix),
+ output_prefix=output_prefix,
elapsed_time=4.567,
)
outputs = json.loads((output_prefix / "outputs.json").read_text())
assert outputs == [
{
- "file_name": "file1",
+ "example_id": "file1",
"main_language": "english",
"document_type": "print",
"normalized_levenshtein_similarity": 0.9,
@@ -95,5 +100,4 @@ def fake_batch_evaluate(
assert all_metrics["aggregate_metrics"]["llm_cost ($)"] == 1.2
assert all_metrics["aggregate_metrics"]["azure_cost ($)"] == 2.3
assert all_metrics["aggregate_metrics"]["elapsed_time (s)"] == 4.6
-
assert combined == all_metrics
diff --git a/tests/test_tooling_support.py b/tests/test_tooling_support.py
new file mode 100644
index 0000000..af2dabe
--- /dev/null
+++ b/tests/test_tooling_support.py
@@ -0,0 +1,302 @@
+from __future__ import annotations
+
+import importlib
+from types import SimpleNamespace
+from typing import cast
+
+import datasets
+import pytest
+from PIL import Image
+
+import tooling.benchmarking.dataset as dataset_module
+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
+
+evaluate_page_module = importlib.import_module("tooling.evaluation.evaluate_page")
+
+
+def test_extract_actual_text_from_xml_handles_plain_text_namespaces_and_parse_errors(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ warnings: list[str] = []
+
+ class _FakeLogger:
+ def warning(self, message: str, *args: object) -> None:
+ warnings.append(message % args if args else message)
+
+ monkeypatch.setattr("tooling.evaluation.xml_utils.logger", _FakeLogger())
+
+ xml_content = """
+
+
+
+ Body line
+
+ Ignore me
+
+
+ """
+
+ 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]
+
+
+def test_normalize_text_for_evaluation_handles_markdown_linebreaks_and_substitutions() -> None:
+ text = "A~word\n\n[figure 3]\nfoo-\nbar – baz ſ \ueada"
+
+ normalized = normalization_module.normalize_text_for_evaluation(text)
+
+ assert normalized == "aword foobar - baz s st"
+
+
+def test_normalize_text_for_evaluation_supports_arabic_normalization_and_missing_dependency(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(normalization_module, "strip_tashkeel", lambda value: value + "1")
+ monkeypatch.setattr(normalization_module, "strip_harakat", lambda value: value + "2")
+ monkeypatch.setattr(normalization_module, "strip_lastharaka", lambda value: value + "3")
+ monkeypatch.setattr(normalization_module, "strip_tatweel", lambda value: value + "4")
+ monkeypatch.setattr(normalization_module, "normalize_hamza", lambda value: value + "5")
+
+ assert normalization_module.normalize_text_for_evaluation("AR", normalize_arabic=True) == "ar12345"
+
+ monkeypatch.setattr(normalization_module, "strip_tashkeel", None)
+ with pytest.raises(ModuleNotFoundError, match="pyarabic"):
+ normalization_module.normalize_text_for_evaluation("AR", normalize_arabic=True)
+
+
+def test_has_long_repetition_distinguishes_repeated_suffixes() -> None:
+ assert has_long_repetition("a") is False
+ assert has_long_repetition("abcdef") is False
+ assert has_long_repetition("xyzxyzxyz") is True
+
+
+def test_load_dataset_split_uses_parquet_shards_and_falls_back_to_dataset(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ parquet_calls: list[tuple[str, dict[str, object]]] = []
+
+ class _BuilderWithFiles:
+ config = SimpleNamespace(data_files={"dev": ["dev.parquet"]})
+ info = SimpleNamespace(features={"keep": "feature-keep", "other": "feature-other"})
+
+ class _BuilderWithoutFiles:
+ config = SimpleNamespace(data_files={})
+ info = SimpleNamespace(features=None)
+
+ monkeypatch.setattr(datasets, "Features", lambda mapping: {"wrapped": mapping})
+ monkeypatch.setattr(
+ datasets,
+ "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())
+
+ parquet_result = dataset_module.load_dataset_split("dataset/id", "dev", columns=["keep"])
+
+ monkeypatch.setattr(datasets, "load_dataset_builder", lambda dataset_id: _BuilderWithoutFiles())
+ fallback_result = dataset_module.load_dataset_split("dataset/id", "test")
+
+ assert parquet_result == {
+ "name": "parquet",
+ "kwargs": {
+ "data_files": {"dev": ["dev.parquet"]},
+ "split": "dev",
+ "columns": ["keep"],
+ "features": {"wrapped": {"keep": "feature-keep"}},
+ },
+ }
+ assert fallback_result == {
+ "name": "dataset/id",
+ "kwargs": {"split": "test"},
+ }
+
+
+def test_dataset_subset_and_selection_cover_iterable_and_materialized_paths() -> None:
+ subset = dataset_module.DatasetSubset.from_raw(language=" English ", document_type="Handwritten Page")
+ 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",
+ },
+ ]
+
+ assert subset.is_active() is True
+ assert subset.output_suffixes() == ["language_english", "document_type_handwritten_page"]
+ assert [example["example_id"] for example in selection.select(examples)] == ["two"]
+
+ materialized = datasets.Dataset.from_list(
+ [
+ {
+ "main_language": "english",
+ "document_type": "handwritten page",
+ "example_id": "one",
+ },
+ {
+ "main_language": "english",
+ "document_type": "handwritten page",
+ "example_id": "two",
+ },
+ {
+ "main_language": "english",
+ "document_type": "print",
+ "example_id": "three",
+ },
+ ]
+ )
+ selected = selection._select_materialized_dataset(materialized)
+ assert selected.num_rows == 1
+ assert cast("str", selected[0]["example_id"]) == "two"
+
+ no_count_dataset = SimpleNamespace(filter=lambda *_args, **_kwargs: "filtered", num_rows="unknown")
+ assert selection._select_materialized_dataset(no_count_dataset) == "filtered"
+
+
+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",
+ }
+
+ monkeypatch.setattr(
+ evaluate_page_module,
+ "_compute_text_metrics_core",
+ lambda *args, **kwargs: (_ for _ in ()).throw(RuntimeError("boom")),
+ )
+ failed = evaluate_page_module.calculate_metrics((example, "predicted"))
+ assert failed["is_empty"] == 1.0
+ assert failed["normalized_gold_text"] == "gold"
+
+ 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,
+ },
+ ),
+ ]
+ )
+ assert aggregate == {"normalized_levenshtein_similarity": 0.75, "is_empty": 0.5}
+ assert len(rows) == 2
+
+
+def test_evaluate_page_metric_helpers_cover_initialization_and_single_batch_paths(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ assert evaluate_page_module.levenshtein_distance("abc", "adc", max_cost=1) == 1
+
+ monkeypatch.setattr(evaluate_page_module, "nltk", None)
+ monkeypatch.setattr(evaluate_page_module, "bleu_metric", None)
+ with pytest.raises(ModuleNotFoundError, match="nltk"):
+ evaluate_page_module.initialize_metrics()
+
+ monkeypatch.setattr(evaluate_page_module, "initialize_metrics", lambda: None)
+ monkeypatch.setattr(
+ evaluate_page_module,
+ "evaluate_page",
+ lambda inputs: cast(
+ "PageEvaluationResult",
+ {
+ "example_id": inputs[0]["example_id"],
+ "normalized_levenshtein_similarity": 1.0,
+ "is_empty": 0.0,
+ },
+ ),
+ )
+ aggregate, rows = evaluate_page_module.batch_evaluate(
+ dataset=[
+ cast(
+ "BenchmarkDatasetExample",
+ {
+ "image": "image",
+ "cleaned_transcription": "",
+ "dataset_id": "dataset-1",
+ "document_type": "print",
+ "example_id": "row-1",
+ "main_language": "English",
+ "main_script": "Latin",
+ },
+ )
+ ],
+ predicted_texts=["predicted"],
+ )
+
+ assert aggregate == {"normalized_levenshtein_similarity": 1.0, "is_empty": 0.0}
+ assert rows[0]["example_id"] == "row-1"
+
+
+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(
+ evaluate_page_module,
+ "bleu_metric",
+ SimpleNamespace(compute=lambda *_args, **_kwargs: {"bleu": 0.5}),
+ )
+
+ result = evaluate_page_module.calculate_metrics_from_text("pred", "gold", "English", "Latin")
+ assert result["bleu"] == 0.5
+
+ errors: list[str] = []
+
+ class _FakeLogger:
+ def error(self, message: str, *args: object) -> None:
+ errors.append(message % args if args else message)
+
+ monkeypatch.setattr(evaluate_page_module, "logger", _FakeLogger())
+ monkeypatch.setattr(
+ evaluate_page_module,
+ "strip_ocr_output_tag",
+ lambda text: (_ for _ in ()).throw(ValueError("bad")),
+ )
+
+ failed = evaluate_page_module._compute_text_metrics_core("pred", "gold", "English", "Latin")
+
+ 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]
diff --git a/tests/test_vllm_config.py b/tests/test_vllm_config.py
deleted file mode 100644
index 3c24e29..0000000
--- a/tests/test_vllm_config.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""Tests for vLLM docker helpers."""
-
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Any
-
-import pytest
-
-from churro.config.settings import (
- AzureDocumentIntelligenceSettings,
- AzureOpenAISettings,
- ChurroSettings,
- LocalRuntimeSettings,
- TokenSettings,
- VertexAISettings,
-)
-from churro.utils.docker import vllm as vllm_module
-
-
-def _make_settings(*, port: int) -> ChurroSettings:
- return ChurroSettings(
- env_file=Path("dummy.env"),
- azure_openai=AzureOpenAISettings(api_base=None, api_version=None, api_key=None),
- azure_document_intelligence=AzureDocumentIntelligenceSettings(
- endpoint=None,
- api_key=None,
- ),
- vertex_ai=VertexAISettings(
- project_id=None,
- location="us-east5",
- document_ai_location="us",
- ocr_processor_id=None,
- ocr_processor_version=None,
- ),
- google_cloud_project=None,
- local=LocalRuntimeSettings(vllm_port=port, huggingface_token=None),
- tokens=TokenSettings(openai=None, mistral=None),
- )
-
-
-def test_maybe_start_vllm_uses_injected_port(
- monkeypatch: pytest.MonkeyPatch,
-) -> None:
- """Injected settings should control the exposed host port."""
- test_engine = "unit-test-engine"
- model_info: dict[str, Any] = {
- "provider_model": "vllm/org-model",
- "max_completion_tokens": 1024,
- "hf_repo": "org/model",
- }
- monkeypatch.setitem(vllm_module.MODEL_MAP, test_engine, [model_info])
-
- captured: dict[str, Any] = {}
-
- def fake_start_vllm_server(**kwargs: object) -> str:
- captured.update(kwargs)
- return "container"
-
- monkeypatch.setattr(vllm_module, "start_vllm_server", fake_start_vllm_server)
-
- settings = _make_settings(port=4321)
- container = vllm_module.maybe_start_vllm_server_for_engine(
- engine=test_engine,
- system="llm",
- settings=settings,
- )
-
- assert container == "container"
- assert captured["host_port"] == 4321
diff --git a/tooling/__init__.py b/tooling/__init__.py
new file mode 100644
index 0000000..0f0760d
--- /dev/null
+++ b/tooling/__init__.py
@@ -0,0 +1,5 @@
+"""Repo-only tooling for evaluation and benchmarking.
+
+This directory is intentionally outside `src/` so it is not shipped as part of the
+published `churro-ocr` package.
+"""
diff --git a/tooling/benchmarking/__init__.py b/tooling/benchmarking/__init__.py
new file mode 100644
index 0000000..98fc2d3
--- /dev/null
+++ b/tooling/benchmarking/__init__.py
@@ -0,0 +1 @@
+"""Repo-only benchmarking helpers built on the current CHURRO library surface."""
diff --git a/tooling/benchmarking/benchmark.py b/tooling/benchmarking/benchmark.py
new file mode 100644
index 0000000..2ca1622
--- /dev/null
+++ b/tooling/benchmarking/benchmark.py
@@ -0,0 +1,470 @@
+"""Benchmark helpers for running repo-local OCR evaluations on CHURRO-DS."""
+
+from __future__ import annotations
+
+import argparse
+import asyncio
+from collections.abc import Iterable
+from dataclasses import dataclass
+from pathlib import Path
+import sys
+import threading
+from time import time
+from typing import Any
+
+from PIL import Image
+from tqdm import tqdm
+
+_REPO_SRC_PATH = Path(__file__).resolve().parents[2] / "src"
+_REPO_SRC_PATH_STR = str(_REPO_SRC_PATH)
+if _REPO_SRC_PATH_STR in sys.path:
+ 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.ocr import BatchOCRBackend, OCRBackend, OCRBackendLike
+from churro_ocr.page_detection import DocumentPage
+from churro_ocr.providers import (
+ AzureDocumentIntelligenceOptions,
+ build_ocr_backend,
+ HuggingFaceOptions,
+ LiteLLMTransportConfig,
+ MistralOptions,
+ OCRBackendSpec,
+ OpenAICompatibleOptions,
+ VLLMOptions,
+)
+from tooling.benchmarking.dataset import (
+ DatasetSelection,
+ DatasetSubset,
+ load_dataset_split,
+)
+from tooling.evaluation.metrics import compute_metrics
+from tooling.evaluation.types import BenchmarkDatasetExample, EvaluationExample, to_evaluation_example
+
+CHURRO_DATASET_ID = "stanford-oval/churro-dataset"
+VALID_DATASET_SPLITS = {"dev", "test"}
+VALID_OCR_BACKENDS = {"litellm", "openai-compatible", "azure", "mistral", "hf", "vllm"}
+BENCHMARK_DATASET_COLUMNS = (
+ "image",
+ "cleaned_transcription",
+ "dataset_id",
+ "document_type",
+ "example_id",
+ "main_language",
+ "main_script",
+)
+
+
+@dataclass(slots=True)
+class BenchmarkOptions:
+ """Normalized options for dataset benchmarking."""
+
+ backend: str
+ dataset_split: str
+ language: str | None = None
+ document_type: str | None = None
+ model: str | None = None
+ input_size: int = 0
+ offset: int = 0
+ output_dir: Path | None = None
+ max_concurrency: int = 10
+ endpoint: str | None = None
+ api_key: str | None = None
+ base_url: str | None = None
+ api_version: str | None = None
+ vllm_gpu_memory_utilization: float | None = None
+ vllm_cpu_offload_gb: float | None = None
+
+ def dataset_subset(self) -> DatasetSubset:
+ """Return the normalized subset filters for this benchmark run."""
+ return DatasetSubset.from_raw(
+ language=self.language,
+ document_type=self.document_type,
+ )
+
+ def dataset_selection(self) -> DatasetSelection:
+ """Return the dataset selection window for this benchmark run."""
+ return DatasetSelection(
+ subset=self.dataset_subset(),
+ offset=self.offset,
+ limit=self.input_size,
+ )
+
+
+def build_parser(*, add_help: bool = True) -> argparse.ArgumentParser:
+ """Create the benchmark CLI parser."""
+ parser = argparse.ArgumentParser(add_help=add_help)
+ parser.add_argument("--backend", required=True, choices=sorted(VALID_OCR_BACKENDS))
+ parser.add_argument("--dataset-split", required=True, choices=sorted(VALID_DATASET_SPLITS))
+ parser.add_argument("--language", default=None)
+ parser.add_argument("--document-type", default=None)
+ parser.add_argument("--model", default=None)
+ parser.add_argument("--input-size", type=int, default=0)
+ parser.add_argument("--offset", type=int, default=0)
+ parser.add_argument("--output-dir", type=Path, default=None)
+ parser.add_argument("--max-concurrency", type=int, default=32)
+ parser.add_argument("--endpoint", default=None)
+ parser.add_argument("--api-key", default=None)
+ parser.add_argument("--base-url", default=None)
+ parser.add_argument("--api-version", default=None)
+ parser.add_argument("--vllm-gpu-memory-utilization", type=float, default=None)
+ parser.add_argument("--vllm-cpu-offload-gb", type=float, default=None)
+ return parser
+
+
+def parse_args(argv: list[str] | None = None) -> BenchmarkOptions:
+ """Parse benchmark CLI args into a normalized options object."""
+ namespace = build_parser().parse_args(argv)
+ return BenchmarkOptions(
+ backend=namespace.backend,
+ dataset_split=namespace.dataset_split,
+ language=namespace.language,
+ document_type=namespace.document_type,
+ model=namespace.model,
+ input_size=namespace.input_size,
+ offset=namespace.offset,
+ output_dir=namespace.output_dir,
+ max_concurrency=namespace.max_concurrency,
+ endpoint=namespace.endpoint,
+ api_key=namespace.api_key,
+ base_url=namespace.base_url,
+ api_version=namespace.api_version,
+ vllm_gpu_memory_utilization=namespace.vllm_gpu_memory_utilization,
+ vllm_cpu_offload_gb=namespace.vllm_cpu_offload_gb,
+ )
+
+
+def _validate_options(options: BenchmarkOptions) -> int:
+ if options.dataset_split not in VALID_DATASET_SPLITS:
+ logger.error("Invalid dataset split '%s'.", options.dataset_split)
+ return 1
+ if options.input_size < 0 or options.offset < 0:
+ logger.error("input-size and offset must be non-negative.")
+ return 1
+ 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.backend == "litellm" and not options.model:
+ logger.error("--model is required for backend=litellm.")
+ return 1
+ if options.backend == "openai-compatible" and (
+ not options.model or not options.base_url or not options.api_key
+ ):
+ logger.error("--model, --base-url, and --api-key are required for backend=openai-compatible.")
+ return 1
+ if options.backend == "hf" and not options.model:
+ logger.error("--model is required for backend=hf.")
+ return 1
+ if options.backend == "vllm" and not options.model:
+ logger.error("--model is required for backend=vllm.")
+ return 1
+ if options.vllm_gpu_memory_utilization is not None and not (
+ 0.0 < options.vllm_gpu_memory_utilization <= 1.0
+ ):
+ logger.error("--vllm-gpu-memory-utilization must be in the range (0, 1].")
+ return 1
+ if options.vllm_cpu_offload_gb is not None and options.vllm_cpu_offload_gb < 0.0:
+ logger.error("--vllm-cpu-offload-gb must be non-negative.")
+ return 1
+ if options.backend == "azure" and (not options.endpoint or not options.api_key):
+ logger.error("--endpoint and --api-key are required for backend=azure.")
+ return 1
+ if options.backend == "mistral" and not options.api_key:
+ logger.error("--api-key is required for backend=mistral.")
+ return 1
+ return 0
+
+
+def create_output_prefix(options: BenchmarkOptions) -> str:
+ """Create the output directory for benchmark artifacts."""
+ if options.output_dir is not None:
+ output_dir = options.output_dir
+ else:
+ suffix = options.backend
+ if options.model:
+ suffix = f"{suffix}_{options.model.replace('/', '_')}"
+ filter_suffixes = options.dataset_subset().output_suffixes()
+ if filter_suffixes:
+ suffix = "_".join([suffix, *filter_suffixes])
+ output_dir = (
+ Path(__file__).resolve().parents[2] / "workdir" / "results" / options.dataset_split / suffix
+ )
+ output_dir.mkdir(parents=True, exist_ok=True)
+ 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 _default_litellm_cache_dir() -> Path:
+ return Path(__file__).resolve().parents[2] / "workdir" / "cache" / "litellm"
+
+
+def _build_evaluation_example(example: BenchmarkDatasetExample) -> EvaluationExample:
+ """Keep only the fields needed for evaluation after OCR completes."""
+ return to_evaluation_example(example)
+
+def _selected_dataset_examples(
+ dataset_stream: Iterable[BenchmarkDatasetExample],
+ options: BenchmarkOptions,
+) -> Iterable[BenchmarkDatasetExample]:
+ """Yield the requested dataset slice without materializing it upfront."""
+ return options.dataset_selection().select(dataset_stream)
+
+
+def _build_ocr_backend(options: BenchmarkOptions) -> OCRBackendLike:
+ if options.backend == "litellm":
+ assert options.model is not None
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="litellm",
+ model=options.model,
+ transport=LiteLLMTransportConfig(
+ api_base=options.base_url,
+ api_key=options.api_key,
+ api_version=options.api_version,
+ cache_dir=_default_litellm_cache_dir(),
+ ),
+ )
+ )
+ if options.backend == "openai-compatible":
+ assert options.model is not None
+ assert options.base_url is not None
+ assert options.api_key is not None
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="openai-compatible",
+ model=options.model,
+ transport=LiteLLMTransportConfig(
+ api_base=options.base_url,
+ api_key=options.api_key,
+ api_version=options.api_version,
+ ),
+ options=OpenAICompatibleOptions(),
+ )
+ )
+ if options.backend == "azure":
+ assert options.endpoint is not None
+ assert options.api_key is not None
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="azure",
+ model=options.model,
+ options=AzureDocumentIntelligenceOptions(
+ endpoint=options.endpoint,
+ api_key=options.api_key,
+ ),
+ )
+ )
+ if options.backend == "hf":
+ assert options.model is not None
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="hf",
+ model=options.model,
+ options=HuggingFaceOptions(model_kwargs={"device_map": "auto", "torch_dtype": "auto"}),
+ )
+ )
+ if options.backend == "vllm":
+ assert options.model is not None
+ llm_kwargs: dict[str, object] = {}
+ if options.vllm_gpu_memory_utilization is not None:
+ llm_kwargs["gpu_memory_utilization"] = options.vllm_gpu_memory_utilization
+ if options.vllm_cpu_offload_gb is not None:
+ llm_kwargs["cpu_offload_gb"] = options.vllm_cpu_offload_gb
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="vllm",
+ model=options.model,
+ options=VLLMOptions(llm_kwargs=llm_kwargs),
+ )
+ )
+ assert options.api_key is not None
+ return build_ocr_backend(
+ OCRBackendSpec(
+ provider="mistral",
+ model=options.model or "mistral-ocr-latest",
+ options=MistralOptions(api_key=options.api_key),
+ )
+ )
+
+
+def _log_first_benchmark_output(*, options: BenchmarkOptions, text: str) -> None:
+ model_name = options.model or ""
+ logger.info(
+ "First benchmark OCR output for backend=%s model=%s:\n%s",
+ options.backend,
+ model_name,
+ text,
+ )
+
+
+async def _predict_texts(
+ dataset: Iterable[BenchmarkDatasetExample],
+ options: BenchmarkOptions,
+ *,
+ total_pages: int | None = None,
+) -> tuple[list[EvaluationExample], list[str]]:
+ ocr_backend = _build_ocr_backend(options)
+ max_in_flight = max(1, options.max_concurrency)
+ has_logged_first_output = False
+ if isinstance(ocr_backend, BatchOCRBackend):
+ dataset_iterator = iter(dataset)
+ evaluation_examples: list[EvaluationExample] = []
+ predicted_texts: list[str] = []
+ submitted_pages = 0
+
+ with tqdm(total=total_pages, desc="OCR", unit="page") as progress:
+ while True:
+ batch_examples: list[BenchmarkDatasetExample] = []
+ pages: list[DocumentPage] = []
+ for batch_index in range(max_in_flight):
+ try:
+ example = next(dataset_iterator)
+ except StopIteration:
+ break
+ image = example["image"]
+ assert isinstance(image, Image.Image)
+ batch_examples.append(example)
+ evaluation_examples.append(_build_evaluation_example(example))
+ pages.append(DocumentPage(page_index=batch_index, source_index=0, image=image))
+ submitted_pages += 1
+
+ if not pages:
+ 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 "",
+ )
+ has_logged_first_output = True
+ predicted_texts.extend((result.text or "") for result in batch_results)
+ progress.update(len(batch_results))
+ progress.set_postfix(submitted=submitted_pages, in_flight=0, refresh=False)
+
+ return evaluation_examples, predicted_texts
+
+ async def _predict(index: int, image: Image.Image) -> tuple[int, str]:
+ 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)
+ return index, (result.text or "")
+
+ dataset_iterator = iter(dataset)
+ evaluation_examples: list[EvaluationExample] = []
+ pending_tasks: set[asyncio.Task[tuple[int, str]]] = set()
+ predicted_texts: list[str] = []
+ next_index = 0
+ wait_poll_seconds = 1.0
+
+ def _update_progress_status(progress: tqdm[object], *, force_refresh: bool = False) -> None:
+ progress.set_postfix(
+ submitted=next_index,
+ in_flight=len(pending_tasks),
+ refresh=False,
+ )
+ if force_refresh:
+ progress.refresh()
+
+ def _progress_heartbeat(progress: tqdm[object], stop_event: threading.Event) -> None:
+ while not stop_event.wait(wait_poll_seconds):
+ _update_progress_status(progress, force_refresh=True)
+
+ with tqdm(total=total_pages, desc="OCR", unit="page") as progress:
+ heartbeat_stop_event = threading.Event()
+ heartbeat_thread = threading.Thread(
+ target=_progress_heartbeat,
+ args=(progress, heartbeat_stop_event),
+ daemon=True,
+ )
+ heartbeat_thread.start()
+ _update_progress_status(progress, force_refresh=True)
+ try:
+ while True:
+ while len(pending_tasks) < max_in_flight:
+ try:
+ example = next(dataset_iterator)
+ except StopIteration:
+ break
+ image = example["image"]
+ assert isinstance(image, Image.Image)
+ evaluation_examples.append(_build_evaluation_example(example))
+ predicted_texts.append("")
+ pending_tasks.add(asyncio.create_task(_predict(next_index, image)))
+ next_index += 1
+ _update_progress_status(progress)
+
+ if not pending_tasks:
+ break
+
+ done_tasks, pending_tasks = await asyncio.wait(
+ pending_tasks,
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+ for task in done_tasks:
+ index, text = await task
+ predicted_texts[index] = text
+ if not has_logged_first_output and index == 0:
+ _log_first_benchmark_output(options=options, text=text)
+ has_logged_first_output = True
+ progress.update(1)
+ _update_progress_status(progress)
+ except Exception:
+ for task in pending_tasks:
+ task.cancel()
+ await asyncio.gather(*pending_tasks, return_exceptions=True)
+ raise
+ finally:
+ heartbeat_stop_event.set()
+ heartbeat_thread.join(timeout=wait_poll_seconds * 2)
+ _update_progress_status(progress, force_refresh=True)
+
+ return evaluation_examples, predicted_texts
+
+
+async def run(options: BenchmarkOptions) -> int:
+ """Execute a benchmark run against CHURRO-DS."""
+ validation_status = _validate_options(options)
+ 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, predicted_texts = await _predict_texts(
+ dataset,
+ options,
+ total_pages=total_pages,
+ )
+ elapsed_time = time() - start_time
+
+ assert len(evaluation_examples) == len(predicted_texts), (
+ f"Mismatch in dataset size ({len(evaluation_examples)}) and predictions ({len(predicted_texts)})."
+ )
+ compute_metrics(evaluation_examples, predicted_texts, output_prefix, elapsed_time)
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ """CLI entrypoint for repo-local benchmarking."""
+ return asyncio.run(run(parse_args(argv)))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/tooling/benchmarking/dataset.py b/tooling/benchmarking/dataset.py
new file mode 100644
index 0000000..d757491
--- /dev/null
+++ b/tooling/benchmarking/dataset.py
@@ -0,0 +1,133 @@
+"""CHURRO dataset selection helpers for benchmark evaluation runs."""
+
+from __future__ import annotations
+
+from collections.abc import Iterable, Sequence
+from dataclasses import dataclass
+from itertools import islice
+from typing import Any
+
+from tooling.evaluation.types import BenchmarkDatasetExample, EvaluationExample
+
+
+def _normalize_filter_value(value: str | None) -> str | None:
+ """Normalize subset filter values for case-insensitive matching."""
+ if value is None:
+ return None
+ normalized_value = value.strip()
+ return normalized_value.casefold() if normalized_value else None
+
+
+def load_dataset_split(
+ dataset_id: str,
+ split: str,
+ *,
+ columns: Sequence[str] | None = None,
+) -> Any:
+ """Load one dataset split directly from its parquet shards."""
+ from datasets import Features, load_dataset, load_dataset_builder
+
+ builder = load_dataset_builder(dataset_id)
+ split_files = getattr(builder.config, "data_files", {}).get(split)
+ if not split_files:
+ return load_dataset(dataset_id, split=split)
+
+ load_kwargs: dict[str, object] = {
+ "data_files": {split: split_files},
+ "split": split,
+ }
+ if columns is not None:
+ load_kwargs["columns"] = list(columns)
+ features = getattr(builder.info, "features", None)
+ if features is not None:
+ load_kwargs["features"] = Features({column: features[column] for column in columns})
+ return load_dataset("parquet", **load_kwargs)
+
+
+@dataclass(frozen=True, slots=True)
+class DatasetSubset:
+ """Normalized subset filters applied to the CHURRO dataset stream."""
+
+ language: str | None = None
+ document_type: str | None = None
+
+ @classmethod
+ def from_raw(cls, *, language: str | None, document_type: str | None) -> DatasetSubset:
+ """Create a normalized subset from raw CLI option values."""
+ return cls(
+ language=_normalize_filter_value(language),
+ document_type=_normalize_filter_value(document_type),
+ )
+
+ def is_active(self) -> bool:
+ """Return whether any subset filter is active."""
+ return self.language is not None or self.document_type is not None
+
+ 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
+
+ def output_suffixes(self) -> list[str]:
+ """Build stable directory suffixes for filtered benchmark runs."""
+ suffixes: list[str] = []
+ if self.language is not None:
+ suffixes.append(f"language_{self.language.replace(' ', '_')}")
+ if self.document_type is not None:
+ suffixes.append(f"document_type_{self.document_type.replace(' ', '_')}")
+ return suffixes
+
+
+@dataclass(frozen=True, slots=True)
+class DatasetSelection:
+ """Windowing and subset filters for one benchmark evaluation run."""
+
+ subset: DatasetSubset
+ offset: int = 0
+ limit: int = 0
+
+ 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)
+
+ 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:
+ """Apply subset filters and slicing to a materialized HF dataset."""
+ selected = dataset
+ if self.subset.is_active():
+ selected = selected.filter(
+ self._matches_materialized_row,
+ input_columns=["main_language", "document_type"],
+ )
+
+ if self.offset <= 0 and self.limit <= 0:
+ return selected
+
+ total_rows = getattr(selected, "num_rows", None)
+ if not isinstance(total_rows, int):
+ return selected
+
+ 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))
+
+ 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
+ ):
+ return False
+ return True
diff --git a/tooling/evaluation/__init__.py b/tooling/evaluation/__init__.py
new file mode 100644
index 0000000..f6a5845
--- /dev/null
+++ b/tooling/evaluation/__init__.py
@@ -0,0 +1,25 @@
+"""Repo-only evaluation utilities for benchmark workflows."""
+
+from tooling.evaluation.evaluate_page import (
+ aggregate_results,
+ batch_evaluate,
+ calculate_metrics,
+ calculate_metrics_from_text,
+ evaluate_page,
+)
+from tooling.evaluation.metrics import (
+ calculate_language_and_type_metrics,
+ compute_metrics,
+ to_rounded_percentage,
+)
+
+__all__ = [
+ "aggregate_results",
+ "batch_evaluate",
+ "calculate_language_and_type_metrics",
+ "calculate_metrics",
+ "calculate_metrics_from_text",
+ "compute_metrics",
+ "evaluate_page",
+ "to_rounded_percentage",
+]
diff --git a/tooling/evaluation/evaluate_page.py b/tooling/evaluation/evaluate_page.py
new file mode 100644
index 0000000..587fd16
--- /dev/null
+++ b/tooling/evaluation/evaluate_page.py
@@ -0,0 +1,227 @@
+"""Per-page evaluation helpers for repo-only benchmark tooling."""
+
+from __future__ import annotations
+
+import multiprocessing
+from typing import Any
+
+try: # pragma: no cover - optional dependency
+ import nltk
+except ModuleNotFoundError: # pragma: no cover - optional dependency
+ nltk = None # type: ignore[assignment]
+from rapidfuzz import distance as rf_distance
+from tqdm import tqdm
+
+from churro_ocr._internal.logging import logger
+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
+
+bleu_metric: Any | None = None
+
+
+def initialize_metrics() -> None:
+ """Lazily load BLEU resources."""
+ global bleu_metric
+ if bleu_metric is not None:
+ return
+ if nltk is None:
+ raise ModuleNotFoundError("BLEU evaluation requires the optional dependency 'nltk'.")
+ 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
+ nltk.download("wordnet", quiet=True)
+ nltk.download("punkt_tab", quiet=True)
+ nltk.download("omw-1.4", quiet=True)
+ bleu_metric = evaluate.load("bleu")
+
+
+def levenshtein_distance(a: str, b: str, max_cost: int | None = None) -> int:
+ """Compute Levenshtein distance with optional cutoff."""
+ if max_cost is not None:
+ return rf_distance.Levenshtein.distance(a, b, score_cutoff=max_cost)
+ return rf_distance.Levenshtein.distance(a, b)
+
+
+def _compute_text_metrics_core(
+ predicted_text: str,
+ gold_text: str,
+ language: str,
+ script: str,
+) -> PageEvaluationMetrics:
+ bleu_result = 0.0
+ normalized_levenshtein_similarity = 0.0
+ has_repetition_flag = False
+ is_empty = 0.0
+
+ try:
+ predicted_text = strip_ocr_output_tag(predicted_text)
+ predicted_text = extract_actual_text_from_xml(predicted_text)
+ predicted_text = normalize_text_for_evaluation(
+ predicted_text,
+ normalize_arabic=language in {"Arabic", "Persian"},
+ )
+ is_empty = 1.0 if not predicted_text.strip() else 0.0
+
+ gold_text = strip_ocr_output_tag(gold_text)
+ gold_text = extract_actual_text_from_xml(gold_text)
+ gold_text = normalize_text_for_evaluation(
+ gold_text,
+ normalize_arabic=language in {"Arabic", "Persian"},
+ )
+
+ denominator = max(len(predicted_text), len(gold_text))
+ if denominator == 0:
+ normalized_levenshtein_similarity = 1.0
+ else:
+ normalized_levenshtein_similarity = (
+ 1 - levenshtein_distance(predicted_text, gold_text) / denominator
+ )
+
+ has_repetition_flag = has_long_repetition(predicted_text)
+
+ if is_empty != 1.0:
+ assert bleu_metric is not None
+ bleu_result = bleu_metric.compute(
+ predictions=[predicted_text],
+ references=[[gold_text]],
+ )["bleu"]
+ except Exception as exc: # pragma: no cover - defensive guard
+ logger.error("Error in metric computation: %s", exc)
+
+ return {
+ "normalized_levenshtein_similarity": normalized_levenshtein_similarity,
+ "repetition": float(has_repetition_flag),
+ "is_empty": is_empty,
+ "bleu": bleu_result,
+ "normalized_predicted_text": predicted_text,
+ "normalized_gold_text": gold_text,
+ "main_language": language,
+ "main_script": script,
+ }
+
+
+def _extract_metric_inputs(example: MetricInputExample) -> tuple[str, str, str, str]:
+ """Extract the fields needed to compute metrics for one example."""
+ return (
+ str(example["cleaned_transcription"]),
+ str(example["main_language"]),
+ str(example["main_script"]),
+ str(example["example_id"]),
+ )
+
+
+def _build_failed_metrics(
+ *,
+ predicted_text: str,
+ gold_text: str,
+ language: str,
+ script: str,
+) -> PageEvaluationMetrics:
+ """Return the fallback metric row used when evaluation fails."""
+ return {
+ "normalized_levenshtein_similarity": 0.0,
+ "repetition": 0.0,
+ "is_empty": 1.0,
+ "bleu": 0.0,
+ "normalized_predicted_text": predicted_text,
+ "normalized_gold_text": gold_text,
+ "main_language": language,
+ "main_script": script,
+ }
+
+
+def calculate_metrics_from_text(
+ predicted_text: str,
+ gold_text: str,
+ language: str,
+ script: str,
+) -> PageEvaluationMetrics:
+ """Evaluate metrics from raw predicted and gold text."""
+ initialize_metrics()
+ return _compute_text_metrics_core(predicted_text, gold_text, language, script)
+
+
+def calculate_metrics(inputs: tuple[MetricInputExample, str]) -> PageEvaluationMetrics:
+ """Evaluate metrics for one dataset example."""
+ example, predicted_text = inputs
+ gold_text, main_language, main_script, example_id = _extract_metric_inputs(example)
+ try:
+ return _compute_text_metrics_core(
+ predicted_text=predicted_text,
+ gold_text=gold_text,
+ language=main_language,
+ script=main_script,
+ )
+ except Exception 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,
+ gold_text=gold_text,
+ language=main_language,
+ script=main_script,
+ )
+
+
+def evaluate_page(inputs: tuple[EvaluationExample, str]) -> PageEvaluationResult:
+ """Evaluate a single predicted transcription against one dataset example."""
+ example, predicted_text = inputs
+ metrics_dict = calculate_metrics((example, predicted_text))
+ metrics_dict["example_id"] = example["example_id"]
+ metrics_dict["predicted_text"] = predicted_text
+ metrics_dict["gold_text"] = example["cleaned_transcription"]
+ metrics_dict["main_language"] = example["main_language"]
+ metrics_dict["main_script"] = example["main_script"]
+ metrics_dict["document_type"] = example["document_type"]
+ metrics_dict["dataset_id"] = example["dataset_id"]
+ return metrics_dict
+
+
+def aggregate_results(
+ results: list[PageEvaluationResult],
+) -> tuple[dict[str, float], list[PageEvaluationResult]]:
+ """Average numeric metrics across all page-level results."""
+ if not results:
+ return {}, []
+
+ aggregated_metrics = {
+ key: 0.0 for key, value in results[0].items() if isinstance(value, int | float | bool)
+ }
+ for metric_row in results:
+ for key, value in metric_row.items():
+ if key in aggregated_metrics and isinstance(value, int | float | bool):
+ aggregated_metrics[key] += float(value)
+ averaged = {key: value / len(results) for key, value in aggregated_metrics.items()}
+ return averaged, results
+
+
+def batch_evaluate(
+ dataset: list[EvaluationExample],
+ predicted_texts: list[str],
+) -> tuple[dict[str, float], list[PageEvaluationResult]]:
+ """Evaluate pages in parallel and return aggregate plus per-example metrics."""
+ initialize_metrics()
+ if len(dataset) <= 1:
+ results = [evaluate_page(pair) for pair in zip(dataset, predicted_texts, strict=False)]
+ return aggregate_results(results)
+
+ processes = min(8, max(1, multiprocessing.cpu_count()))
+ with multiprocessing.Pool(processes=processes) as pool:
+ results = list(
+ tqdm(
+ pool.imap(evaluate_page, zip(dataset, predicted_texts, strict=False)),
+ total=len(dataset),
+ mininterval=0.5,
+ )
+ )
+ return aggregate_results(results)
diff --git a/tooling/evaluation/metrics.py b/tooling/evaluation/metrics.py
new file mode 100644
index 0000000..e4f016b
--- /dev/null
+++ b/tooling/evaluation/metrics.py
@@ -0,0 +1,129 @@
+"""Aggregate metrics and output writers for repo-only benchmark tooling."""
+
+from __future__ import annotations
+
+from collections import defaultdict
+import json
+from pathlib import Path
+from typing import Any
+
+from churro_ocr._internal.logging import logger
+from tooling.evaluation.evaluate_page import batch_evaluate
+from tooling.evaluation.types import EvaluationExample, PageEvaluationResult
+
+
+def _get_llm_total_cost() -> float:
+ try: # pragma: no cover - optional integration path
+ from litellm import completion_cost
+ except ImportError:
+ return 0.0
+ del completion_cost
+ return 0.0
+
+
+def _get_azure_total_cost() -> float:
+ return 0.0
+
+
+def round_metric(value: float | int) -> float:
+ """Round a numeric value to one decimal place."""
+ return float(f"{value:.1f}")
+
+
+def to_rounded_percentage(metrics: dict[str, Any]) -> dict[str, Any]:
+ """Convert numeric values to percentages rounded to one decimal place."""
+ return {
+ key: round_metric(value * 100) if isinstance(value, int | float) else value
+ for key, value in metrics.items()
+ }
+
+
+def calculate_language_and_type_metrics(
+ outputs: list[PageEvaluationResult],
+ main_metric: str = "normalized_levenshtein_similarity",
+) -> tuple[dict[str, float], dict[str, float], dict[str, float]]:
+ """Compute averages grouped by language, document type, and both."""
+ language_to_metrics: dict[str, list[float]] = defaultdict(list)
+ language_type_to_metrics: dict[str, list[float]] = defaultdict(list)
+ type_to_metrics: dict[str, list[float]] = {"print": [], "handwriting": []}
+
+ for output in outputs:
+ main_language = str(output["main_language"])
+ metric = float(output[main_metric])
+ document_type = str(output["document_type"])
+ language_to_metrics[main_language].append(metric)
+ type_to_metrics.setdefault(document_type, []).append(metric)
+ language_type_to_metrics[f"{main_language}_{document_type}"].append(metric)
+
+ averaged_language = {
+ language: sum(values) / len(values) if values else 0.0
+ for language, values in language_to_metrics.items()
+ }
+ averaged_type = {
+ document_type: sum(values) / len(values) if values else 0.0
+ 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()
+ }
+ return averaged_language, averaged_type, averaged_language_type
+
+
+def _build_output_rows(
+ dataset: list[EvaluationExample],
+ per_example_outputs: list[PageEvaluationResult],
+) -> list[PageEvaluationResult]:
+ """Attach stable example ids to per-example outputs before writing them."""
+ return [
+ {"example_id": str(example["example_id"]), **evaluation_output}
+ for evaluation_output, example in zip(per_example_outputs, dataset, strict=False)
+ ]
+
+
+def compute_metrics(
+ dataset: list[EvaluationExample],
+ predicted_texts: list[str],
+ output_prefix: str | Path,
+ elapsed_time: float,
+ main_metric: str = "normalized_levenshtein_similarity",
+) -> dict[str, Any]:
+ """Compute aggregate metrics and write benchmark output files."""
+ sanitized_predictions = [prediction or "" for prediction in predicted_texts]
+ aggregate_metrics, per_example_outputs = batch_evaluate(dataset, sanitized_predictions)
+ outputs = _build_output_rows(dataset, per_example_outputs)
+
+ output_dir = Path(output_prefix)
+ output_dir.mkdir(parents=True, exist_ok=True)
+ (output_dir / "outputs.json").write_text(json.dumps(outputs, indent=2, ensure_ascii=False))
+
+ language_metrics, type_metrics, language_type_metrics = calculate_language_and_type_metrics(
+ outputs,
+ main_metric,
+ )
+
+ aggregate_metrics = to_rounded_percentage(aggregate_metrics)
+ language_metrics = to_rounded_percentage(language_metrics)
+ type_metrics = to_rounded_percentage(type_metrics)
+ language_type_metrics = to_rounded_percentage(language_type_metrics)
+ aggregate_metrics["llm_cost ($)"] = round_metric(_get_llm_total_cost())
+ aggregate_metrics["azure_cost ($)"] = round_metric(_get_azure_total_cost())
+ aggregate_metrics["elapsed_time (s)"] = round_metric(elapsed_time)
+
+ combined_metrics = {
+ "main_language_metrics": language_metrics,
+ "type_metrics": type_metrics,
+ "aggregate_metrics": aggregate_metrics,
+ "main_language_and_type_metrics": language_type_metrics,
+ }
+ (output_dir / "all_metrics.json").write_text(json.dumps(combined_metrics, indent=2))
+
+ logger.info("Average metrics per document type: %s", json.dumps(type_metrics, indent=2))
+ logger.info("Average metrics per main language: %s", json.dumps(language_metrics, indent=2))
+ logger.info(
+ "Average metrics per main language and type: %s",
+ json.dumps(language_type_metrics, indent=2),
+ )
+ logger.info("Aggregated metrics: %s", json.dumps(aggregate_metrics, indent=2))
+
+ return combined_metrics
diff --git a/tooling/evaluation/normalization.py b/tooling/evaluation/normalization.py
new file mode 100644
index 0000000..2c6f9bd
--- /dev/null
+++ b/tooling/evaluation/normalization.py
@@ -0,0 +1,107 @@
+"""Normalization helpers used by repo-only evaluation tooling."""
+
+from __future__ import annotations
+
+import re
+import unicodedata
+from collections.abc import Callable
+from typing import cast
+
+
+normalize_hamza: Callable[..., str] | None
+strip_harakat: Callable[..., str] | None
+strip_lastharaka: Callable[..., str] | None
+strip_tashkeel: Callable[..., str] | None
+strip_tatweel: Callable[..., str] | None
+
+try: # pragma: no cover - optional dependency
+ from pyarabic.araby import (
+ normalize_hamza as _normalize_hamza,
+ strip_harakat as _strip_harakat,
+ strip_lastharaka as _strip_lastharaka,
+ strip_tashkeel as _strip_tashkeel,
+ 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)
+except ModuleNotFoundError: # pragma: no cover - optional dependency
+ normalize_hamza = None
+ strip_harakat = None
+ strip_lastharaka = None
+ strip_tashkeel = None
+ strip_tatweel = None
+
+SUBSTITUTIONS = {
+ "\ueada": "st",
+ "\ueec5": "ct",
+ "\ueba6": "ss",
+ "\ueba2": "si",
+ "\ueba7": "ssi",
+ "\ueba3": "sl",
+ "’": "'",
+ "¬": "-",
+}
+SUBSTITUTION_PATTERN = re.compile("|".join(map(re.escape, SUBSTITUTIONS.keys())))
+
+
+def normalize_characters(text: str, *, keep_long_s: bool = True) -> str:
+ """Replace document-specific glyphs with normalized equivalents."""
+ text = re.sub(r"(?<=\d)(?=[↉½⅓¼⅕⅙⅐⅛⅑⅒⅔⅖¾⅗⅜⅘⅚⅞])", " ", text)
+
+ placeholder = "\ue000"
+ if keep_long_s:
+ text = text.replace("ſ", placeholder)
+
+ text = unicodedata.normalize("NFKC", text)
+
+ 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
+
+
+def normalize_text_for_evaluation(text: str, *, normalize_arabic: bool = False) -> str:
+ """Normalize raw OCR text before metric computation."""
+ if normalize_arabic:
+ if (
+ strip_tashkeel is None
+ or strip_harakat is None
+ or strip_lastharaka is None
+ 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)
+
+ text = strip_tashkeel_fn(text)
+ text = strip_harakat_fn(text)
+ text = strip_lastharaka_fn(text)
+ text = strip_tatweel_fn(text)
+ text = normalize_hamza_fn(text)
+
+ 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
diff --git a/tooling/evaluation/repetition.py b/tooling/evaluation/repetition.py
new file mode 100644
index 0000000..5c0897f
--- /dev/null
+++ b/tooling/evaluation/repetition.py
@@ -0,0 +1,32 @@
+"""Repetition heuristics used by repo-only evaluation tooling."""
+
+from __future__ import annotations
+
+
+def has_long_repetition(text: str) -> bool:
+ """Return True when the tail of the string is composed of repeated content."""
+ length = len(text)
+ if length < 2:
+ return False
+
+ reversed_text = text[::-1]
+ prefix_function = [0] * length
+ for i in range(1, length):
+ j = prefix_function[i - 1]
+ while j and reversed_text[i] != reversed_text[j]:
+ j = prefix_function[j - 1]
+ if reversed_text[i] == reversed_text[j]:
+ j += 1
+ prefix_function[i] = j
+
+ max_prefix = int(0.8 * length)
+ for prefix_size in range(1, max_prefix + 1):
+ remainder = length - prefix_size
+ if remainder < 2:
+ continue
+ border = prefix_function[remainder - 1]
+ period = remainder - border
+ if border > 0 and remainder % period == 0 and remainder // period >= 2:
+ return True
+
+ return False
diff --git a/tooling/evaluation/types.py b/tooling/evaluation/types.py
new file mode 100644
index 0000000..f650679
--- /dev/null
+++ b/tooling/evaluation/types.py
@@ -0,0 +1,66 @@
+"""Shared dataset and result types for CHURRO tooling evaluation flows."""
+
+from __future__ import annotations
+
+from typing import TypedDict
+
+from PIL import Image
+
+EVALUATION_EXAMPLE_FIELDS = (
+ "cleaned_transcription",
+ "dataset_id",
+ "document_type",
+ "example_id",
+ "main_language",
+ "main_script",
+)
+
+
+class MetricInputExample(TypedDict):
+ """Dataset fields required to evaluate one OCR prediction."""
+
+ cleaned_transcription: str
+ example_id: str
+ main_language: str
+ main_script: str
+
+
+class EvaluationExample(MetricInputExample):
+ """Dataset fields retained after OCR for aggregate evaluation."""
+
+ dataset_id: str
+ document_type: str
+
+
+class BenchmarkDatasetExample(EvaluationExample):
+ """Full CHURRO dataset example used by the benchmark runner."""
+
+ image: Image.Image
+
+
+class PageEvaluationMetrics(TypedDict):
+ """Computed metrics for one evaluated example."""
+
+ normalized_levenshtein_similarity: float
+ repetition: float
+ is_empty: float
+ bleu: float
+ normalized_predicted_text: str
+ normalized_gold_text: str
+ main_language: str
+ main_script: str
+
+
+class PageEvaluationResult(PageEvaluationMetrics):
+ """Metric row enriched with example metadata and raw texts."""
+
+ example_id: str
+ predicted_text: str
+ gold_text: str
+ dataset_id: str
+ document_type: str
+
+
+def to_evaluation_example(example: BenchmarkDatasetExample) -> EvaluationExample:
+ """Keep only the dataset fields needed after OCR completes."""
+ return {field_name: example[field_name] for field_name in EVALUATION_EXAMPLE_FIELDS}
diff --git a/tooling/evaluation/xml_utils.py b/tooling/evaluation/xml_utils.py
new file mode 100644
index 0000000..5e765cd
--- /dev/null
+++ b/tooling/evaluation/xml_utils.py
@@ -0,0 +1,53 @@
+"""XML extraction helpers for repo-only evaluation tooling."""
+
+from __future__ import annotations
+
+import re
+import xml.etree.ElementTree as ET
+
+from churro_ocr._internal.logging import logger
+
+
+def _local_name(tag: str) -> str:
+ if "}" in tag:
+ return tag.rsplit("}", 1)[1]
+ return tag
+
+
+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[^>]*>.*?{tag_name}>", "", xml_content, flags=re.DOTALL)
+ xml_content = re.sub(rf"<{tag_name}\b[^>]*/>", "", xml_content)
+ return xml_content
+
+
+def extract_actual_text_from_xml(xml_content: str) -> str:
+ """Extract text from HistoricalDocument XML, or return the raw input when not XML."""
+ if "HistoricalDocument" not in xml_content:
+ return xml_content
+
+ for tag_name in ("Description", "Deletion", "Illegible", "Gap"):
+ xml_content = _remove_tag(xml_content, tag_name)
+
+ try:
+ root = ET.fromstring(xml_content)
+ except ET.ParseError as exc:
+ logger.warning("Failed to parse XML content during evaluation: %s", exc)
+ return ""
+
+ page_texts: list[str] = []
+ for page in root.iter():
+ if _local_name(page.tag) != "Page":
+ continue
+ section_texts: list[str] = []
+ for child in page.iter():
+ if _local_name(child.tag) not in {"Header", "Body", "Footer"}:
+ continue
+ lines = [line.strip() for line in child.itertext() if line.strip()]
+ if lines:
+ section_texts.append("\n".join(lines))
+ if section_texts:
+ page_texts.append("\n".join(section_texts))
+
+ return "\n\n".join(page_texts).strip()
diff --git a/ty.toml b/ty.toml
new file mode 100644
index 0000000..4175e6d
--- /dev/null
+++ b/ty.toml
@@ -0,0 +1,5 @@
+[src]
+include = ["src", "tests"]
+
+[terminal]
+output-format = "concise"
diff --git a/utils/__init__.py b/utils/__init__.py
deleted file mode 100644
index dfe919d..0000000
--- a/utils/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-"""Utility helpers for the OCR application."""
-
-__all__ = [
- "log_utils",
-]
diff --git a/utils/concurrency.py b/utils/concurrency.py
deleted file mode 100644
index e367069..0000000
--- a/utils/concurrency.py
+++ /dev/null
@@ -1,212 +0,0 @@
-"""Structured concurrency helpers for running async workloads in parallel."""
-
-from __future__ import annotations
-
-import asyncio
-from collections.abc import Awaitable, Callable, Sequence
-from dataclasses import dataclass
-from typing import ParamSpec, Protocol, TypeVar
-
-from tqdm import tqdm
-
-from .log_utils import logger
-
-
-class ProgressReporter(Protocol):
- """Lightweight progress reporter abstraction."""
-
- def start(self, total: int) -> None: ...
-
- def increment(self) -> None: ...
-
- def close(self) -> None: ...
-
-
-class TqdmProgressReporter:
- """Progress reporter backed by tqdm."""
-
- def __init__(self, desc: str, update_interval: float = 1.0) -> None:
- self._desc = desc
- self._update_interval = update_interval
- self._pbar: tqdm | None = None
- self._loop: asyncio.AbstractEventLoop | None = None
- self._tick_handle: asyncio.TimerHandle | None = None
-
- def start(self, total: int) -> None:
- self._pbar = tqdm(
- total=total,
- desc=self._desc,
- smoothing=0,
- leave=False,
- )
- try:
- self._loop = asyncio.get_running_loop()
- except RuntimeError: # pragma: no cover - defensive guard for sync contexts
- self._loop = None
- if self._loop and self._update_interval > 0:
- self._schedule_tick()
-
- def _schedule_tick(self) -> None:
- if not self._loop or self._tick_handle is not None or self._pbar is None:
- # Loop missing, already scheduled, or pbar unavailable
- return
- self._tick_handle = self._loop.call_later(self._update_interval, self._tick)
-
- def _tick(self) -> None:
- self._tick_handle = None
- if self._pbar is None:
- return
- self._pbar.refresh()
- if self._loop and self._update_interval > 0:
- self._schedule_tick()
-
- def increment(self) -> None:
- if self._pbar is not None:
- self._pbar.update(1)
-
- def close(self) -> None:
- if self._tick_handle is not None:
- self._tick_handle.cancel()
- self._tick_handle = None
- if self._pbar is not None:
- self._pbar.close()
- self._pbar = None
- self._loop = None
-
-
-@dataclass(frozen=True)
-class RetryPolicy:
- """Retry configuration for async jobs."""
-
- max_attempts: int = 1
- timeout: float | None = None
- retry_exceptions: tuple[type[BaseException], ...] = (asyncio.TimeoutError,)
- backoff_seconds: float = 0.0
-
- def should_retry(self, exc: BaseException, attempt: int) -> bool:
- if attempt >= self.max_attempts:
- return False
- return isinstance(exc, self.retry_exceptions)
-
-
-P = ParamSpec("P")
-T = TypeVar("T")
-
-
-class ParallelExecutor:
- """Run async callables in parallel with structured error handling."""
-
- def __init__(
- self,
- *,
- max_concurrency: int,
- retry_policy: RetryPolicy | None = None,
- progress_reporter: ProgressReporter | None = None,
- return_exceptions: bool = False,
- ) -> None:
- if max_concurrency < 1:
- raise ValueError("max_concurrency must be >= 1")
- self._max_concurrency = max_concurrency
- self._retry_policy = retry_policy or RetryPolicy()
- self._progress = progress_reporter
- self._return_exceptions = return_exceptions
-
- async def map(
- self,
- fn: Callable[..., Awaitable[T]],
- *iterables: Sequence[object],
- ) -> list[T | None]:
- """Execute `fn` across provided iterables with bounded concurrency."""
- if not iterables:
- return []
-
- lengths = [len(it) for it in iterables]
- if any(length != lengths[0] for length in lengths):
- raise ValueError("All iterables must have the same length.")
-
- jobs = list(enumerate(zip(*iterables, strict=False)))
- total = len(jobs)
- results: list[T | BaseException | None] = [None] * total
-
- queue: asyncio.Queue[tuple[int, tuple[object, ...]]] = asyncio.Queue()
- for job in jobs:
- queue.put_nowait((job[0], tuple(job[1])))
-
- if self._progress:
- self._progress.start(total)
-
- errors: dict[int, BaseException] = {}
-
- async def worker() -> None:
- while True:
- try:
- index, args = queue.get_nowait()
- except asyncio.QueueEmpty:
- break
-
- attempt = 0
- while True:
- attempt += 1
- try:
- coro = fn(*args)
- if self._retry_policy.timeout is not None:
- result = await asyncio.wait_for(
- coro, timeout=self._retry_policy.timeout
- )
- else:
- result = await coro
- results[index] = result
- break
- except Exception as exc:
- if self._retry_policy.should_retry(exc, attempt):
- if self._retry_policy.backoff_seconds > 0:
- await asyncio.sleep(self._retry_policy.backoff_seconds)
- continue
- errors[index] = exc
- if self._return_exceptions:
- results[index] = exc
- else:
- results[index] = None
- logger.exception(f"Parallel executor job {index} failed", exc_info=exc)
- break
- queue.task_done()
- if self._progress:
- self._progress.increment()
-
- async with asyncio.TaskGroup() as tg:
- for _ in range(min(self._max_concurrency, total)):
- tg.create_task(worker())
-
- if self._progress:
- self._progress.close()
-
- if errors and not self._return_exceptions:
- logger.error(
- f"Parallel executor encountered {len(errors)} failed job(s): {list(errors.keys())}."
- )
- return results # type: ignore[return-value]
-
-
-async def run_async_in_parallel(
- async_function: Callable[P, Awaitable[T]],
- *iterables: Sequence[object],
- max_concurrency: int,
- timeout: float = 60 * 60 * 1,
- desc: str = "",
-) -> list[T | None]:
- """Execute an async function over iterables with bounded concurrency.
-
- The helper preserves the previous behaviour of retrying requests that time out
- while returning ``None`` for tasks that ultimately fail.
- """
- reporter = TqdmProgressReporter(desc) if desc else None
- retry_policy = RetryPolicy(
- max_attempts=3 if timeout else 1,
- timeout=timeout,
- )
- executor = ParallelExecutor(
- max_concurrency=max_concurrency,
- retry_policy=retry_policy,
- progress_reporter=reporter,
- )
- return await executor.map(async_function, *iterables)
diff --git a/utils/docker/__init__.py b/utils/docker/__init__.py
deleted file mode 100644
index f56571e..0000000
--- a/utils/docker/__init__.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""High-level Docker utilities package.
-
-This package provides structured helpers for:
- * Starting generic Docker containers (``start_container``)
- * Waiting for a readiness log pattern with inactivity timeout semantics
- (``wait_for_readiness`` / ``start_and_wait_ready``)
- * Launching specialized model serving runtimes (currently vLLM via
- ``start_vllm_server``)
- * Conditionally spinning up local vLLM servers for engines described in
- ``llm.models.MODEL_MAP`` (``maybe_start_vllm_server_for_engine``).
-
-Principles:
- * Keep low-level SDK usage encapsulated (see ``sdk.py``) so higher-level
- code can be easily mocked in tests.
- * Avoid side effects at import time (no client construction until needed).
- * Provide precise docstrings and type hints for clarity.
-
-Public API (re-exported):
- - DockerError
- - DockerContainer
- - start_container
- - wait_for_readiness
- - start_and_wait_ready
- - start_vllm_server
- - maybe_start_vllm_server_for_engine
- - get_hf_repo_for_hosted
- - has_at_least_one_vllm
-"""
-
-from .container import DockerContainer
-from .errors import DockerError
-from .operations import start_and_wait_ready, start_container, wait_for_readiness
-from .servers import start_vllm_server
-from .vllm import (
- get_hf_repo_for_hosted,
- has_at_least_one_vllm,
- maybe_start_vllm_server_for_engine,
-)
-
-
-__all__ = [
- "DockerError",
- "DockerContainer",
- "start_container",
- "wait_for_readiness",
- "start_and_wait_ready",
- "start_vllm_server",
- "maybe_start_vllm_server_for_engine",
- "get_hf_repo_for_hosted",
- "has_at_least_one_vllm",
-]
diff --git a/utils/docker/container.py b/utils/docker/container.py
deleted file mode 100644
index 76ed527..0000000
--- a/utils/docker/container.py
+++ /dev/null
@@ -1,95 +0,0 @@
-"""Dataclass wrapper for a Docker container instance."""
-
-from __future__ import annotations
-
-from collections.abc import Mapping, Sequence
-import contextlib
-from dataclasses import dataclass, field
-from typing import Any
-
-from .errors import DockerError
-from .sdk import container_is_running
-
-
-@dataclass(slots=True)
-class DockerContainer:
- """A lightweight handle to a running Docker container.
-
- Attributes:
- id: Container ID (hash string).
- name: Container name.
- image: Image reference used to create it.
- auto_remove: Whether container auto-removes itself on exit.
- """
-
- id: str
- name: str
- image: str
- _container: Any = field(repr=False)
- auto_remove: bool = True
-
- def is_running(self) -> bool:
- """Return whether the container is still running."""
- return container_is_running(self._container)
-
- def stop(self, timeout: float = 10.0) -> None:
- """Stop the container and remove it if not auto_remove."""
- with contextlib.suppress(Exception):
- self._container.stop(timeout=int(timeout))
- if not self.auto_remove:
- with contextlib.suppress(Exception):
- self._container.remove(force=True)
-
- def logs(self, tail: int | None = None, since: str | None = None) -> str:
- """Return combined stdout/stderr logs as text snapshot."""
- try:
- logs = self._container.logs(tail=tail, since=since, stdout=True, stderr=True)
- if isinstance(logs, bytes | bytearray):
- return logs.decode("utf-8", errors="replace")
- return str(logs)
- except Exception as e: # pragma: no cover
- raise DockerError("Failed to retrieve container logs.") from e
-
- def exec(
- self,
- command: Sequence[str] | str,
- *,
- user: str | None = None,
- workdir: str | None = None,
- environment: Mapping[str, str] | None = None,
- demux: bool = False,
- ) -> tuple[int, str]:
- """Execute a command inside the container and return (exit_code, output)."""
- try:
- res = self._container.exec_run(
- cmd=command,
- user=user,
- workdir=workdir,
- environment=dict(environment) if environment else None,
- demux=demux,
- )
- except Exception as e: # pragma: no cover
- raise DockerError("Failed to exec inside Docker container.") from e
-
- if hasattr(res, "exit_code") and hasattr(res, "output"):
- exit_code, output = res.exit_code, res.output
- else: # Docker SDK variant fallback
- try:
- exit_code, output = res # type: ignore[misc]
- except Exception: # pragma: no cover
- exit_code, output = 1, b""
-
- if demux and isinstance(output, tuple):
- out_b = b"" if output[0] is None else output[0]
- err_b = b"" if output[1] is None else output[1]
- combined = out_b + err_b
- text = combined.decode("utf-8", errors="replace")
- else:
- if isinstance(output, bytes | bytearray):
- text = output.decode("utf-8", errors="replace")
- else:
- text = str(output)
- return exit_code, text
-
-
-__all__ = ["DockerContainer"]
diff --git a/utils/docker/errors.py b/utils/docker/errors.py
deleted file mode 100644
index 8cafbe7..0000000
--- a/utils/docker/errors.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""Custom exception types for Docker helpers."""
-
-from __future__ import annotations
-
-
-class DockerError(RuntimeError):
- """Raised when Docker-related operations fail.
-
- This includes SDK import issues, daemon connectivity, timeouts, or
- container crashes detected during readiness wait.
- """
-
- pass
-
-
-__all__ = ["DockerError"]
diff --git a/utils/docker/logging_utils.py b/utils/docker/logging_utils.py
deleted file mode 100644
index f553237..0000000
--- a/utils/docker/logging_utils.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""Internal logging helpers for Docker utilities.
-
-Separated to keep concerns modular. Not part of the public API.
-"""
-
-from __future__ import annotations
-
-import re
-from re import Pattern
-
-from churro.utils.log_utils import logger
-
-
-try: # Rich may already be installed (used by log_utils)
- from rich.markup import escape as _rich_escape # type: ignore
-except Exception: # pragma: no cover - fallback if Rich missing in some envs
-
- def _rich_escape(s: str) -> str: # type: ignore
- return s.replace("[", "[[") # minimal safe fallback
-
-
-ANSI_ESCAPE_RE: Pattern[str] = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
-
-
-def format_prefix(prefix: str | None) -> str:
- """Return a logging prefix safe for Rich markup.
-
- Args:
- prefix: Optional raw prefix.
-
- Returns:
- Escaped prefix ending with a space if non-empty.
- """
- if not prefix:
- return ""
- p = _rich_escape(prefix.rstrip())
- return p + (" " if not p.endswith(" ") else "")
-
-
-def strip_ansi(text: str) -> str:
- """Remove ANSI escape sequences from text."""
- return ANSI_ESCAPE_RE.sub("", text)
-
-
-def log_multiline(text: str, log_prefix: str | None, level: str = "info") -> None:
- """Log multiline output line-by-line with optional prefix."""
- if not text:
- return
- pf = format_prefix(log_prefix)
- log_fn = getattr(logger, level, logger.info)
- for raw_line in text.splitlines():
- sanitized = strip_ansi(raw_line).replace("\r", "")
- log_fn(f"{pf}{sanitized}")
-
-
-__all__ = ["format_prefix", "strip_ansi", "log_multiline", "ANSI_ESCAPE_RE"]
diff --git a/utils/docker/operations.py b/utils/docker/operations.py
deleted file mode 100644
index c64227f..0000000
--- a/utils/docker/operations.py
+++ /dev/null
@@ -1,315 +0,0 @@
-"""Core container lifecycle operations: start, wait for readiness."""
-
-from __future__ import annotations
-
-from collections import deque
-from collections.abc import Mapping, Sequence
-import contextlib
-import os
-from queue import Empty, Queue
-import re
-from re import Pattern
-import threading
-import time
-
-from churro.utils.log_utils import logger
-
-from .container import DockerContainer
-from .errors import DockerError
-from .logging_utils import format_prefix, strip_ansi
-from .sdk import (
- ensure_docker_sdk,
- make_device_requests,
- stream_logs,
-)
-
-
-__all__ = [
- "start_container",
- "wait_for_readiness",
- "start_and_wait_ready",
-]
-
-
-def start_container(
- *,
- image: str,
- name: str | None = None,
- gpus: str | None = None,
- volumes: Mapping[str, str] | None = None,
- ports: Mapping[int, int] | None = None,
- env: Mapping[str, str] | None = None,
- ipc: str | None = None,
- shm_size: str | None = None,
- network: str | None = None,
- user: str | None = None,
- auto_remove: bool = True,
- cmd: Sequence[str] | None = None,
- force_replace: bool = False,
- pull: bool = True,
-) -> DockerContainer:
- """Start a detached Docker container and return a handle.
-
- Args:
- image: Image reference (``repository[:tag]``) to run.
- name: Optional explicit container name.
- gpus: GPU selection string (e.g. ``"all"``, ``"device=0,1"``, ``"1"``) or ``None``.
- volumes: Host path -> container path mappings (rw mode).
- ports: Host port -> container internal port mappings (tcp assumed).
- env: Environment variables to inject.
- ipc: IPC mode (e.g. ``host``).
- shm_size: Shared memory size (e.g. ``"4g"``) useful for some workloads.
- network: Optional network name.
- user: Run container processes as this user string (``UID[:GID]`` form).
- auto_remove: If True, Docker auto-removes the container on exit.
- cmd: Override default image command with this sequence.
- force_replace: If True and ``name`` is provided, attempt to stop/remove
- any existing container with that name before starting.
- pull: If True, ensure the image is present locally (pull when missing).
-
- Returns:
- DockerContainer: Lightweight wrapper handle for further operations.
-
- Raises:
- DockerError: On image pull failure or container creation failure.
- """
- client = ensure_docker_sdk()
-
- if pull: # ensure image present
- try:
- try:
- client.images.get(image)
- except Exception:
- logger.info(
- f"Pulling Docker image '{image}' ... this may take a while on first use"
- )
- api_client = getattr(client, "api", None)
- if api_client and hasattr(api_client, "pull"):
- api_client.pull(image) # type: ignore[arg-type]
- else: # pragma: no cover
- client.images.pull(image)
- except DockerError:
- raise
- except Exception as e: # pragma: no cover
- raise DockerError(
- f"Failed to pull image '{image}'. Check network, image name, or registry auth."
- ) from e
-
- if force_replace and name:
- for attempt in range(5):
- try:
- existing = client.containers.list(all=True, filters={"name": name})
- except Exception:
- break
- removed_any = False
- for c in existing:
- if getattr(c, "name", "") == name:
- removed_any = True
- with contextlib.suppress(Exception):
- logger.debug(
- f"Stopping existing container '{name}' (attempt {attempt + 1})"
- )
- c.stop(timeout=5)
- with contextlib.suppress(Exception):
- logger.debug(
- f"Removing existing container '{name}' (attempt {attempt + 1})"
- )
- c.remove(force=True)
- if not removed_any:
- break
- time.sleep(0.4)
-
- ports_map: dict[str, int] | None = None
- if ports:
- ports_map = {f"{container}/tcp": host for host, container in ports.items()}
-
- volumes_map: dict[str, dict[str, str]] | None = None
- if volumes:
- volumes_map = {os.path.expanduser(h): {"bind": c, "mode": "rw"} for h, c in volumes.items()}
-
- device_requests = make_device_requests(gpus)
-
- def _run_container() -> object:
- return client.containers.run(
- image=image,
- name=name,
- environment=dict(env) if env else None,
- volumes=volumes_map,
- ports=ports_map,
- ipc_mode=ipc,
- shm_size=shm_size,
- network=network,
- user=user,
- remove=auto_remove,
- detach=True,
- command=list(cmd) if cmd else None,
- device_requests=device_requests,
- )
-
- try:
- container = _run_container()
- except Exception as e:
- conflict_msg = str(e)
- if force_replace and name and "Conflict" in conflict_msg:
- with contextlib.suppress(Exception):
- logger.debug(f"Retrying container create after conflict on name '{name}'")
- existing = client.containers.list(all=True, filters={"name": name})
- for c in existing:
- if getattr(c, "name", "") == name:
- with contextlib.suppress(Exception):
- c.stop(timeout=3)
- with contextlib.suppress(Exception):
- c.remove(force=True)
- time.sleep(0.6)
- try:
- container = _run_container()
- except Exception as e2: # pragma: no cover
- raise DockerError(
- f"Failed to start Docker container after force_replace retry (name='{name}')."
- ) from e2
- else:
- raise DockerError("Failed to start Docker container via SDK.") from e
-
- resolved_name = getattr(container, "name", name or "") or (name or "")
- return DockerContainer(
- id=getattr(container, "id", ""),
- name=resolved_name,
- image=image,
- auto_remove=auto_remove,
- _container=container,
- )
-
-
-def wait_for_readiness(
- container: DockerContainer,
- *,
- ready_pattern: str | Pattern[str],
- ready_timeout: float = 120.0,
- check_interval: float = 0.5,
- capture_tail_lines: int = 4000,
- log_prefix: str | None = None,
-) -> None:
- """Block until container logs match regex or fail.
-
- Args:
- container: Running container handle.
- ready_pattern: Regex pattern or string to signal readiness.
- ready_timeout: Inactivity timeout in seconds (time since last log line).
- check_interval: Poll interval for waiting on new log lines.
- capture_tail_lines: Maximum number of log lines retained internally.
- log_prefix: Optional prefix prepended to each log line when printing.
-
- Behavior:
- Uses an *inactivity timeout* semantic: the timer resets on every new
- log line. If no new line arrives for ``ready_timeout`` seconds before
- the readiness pattern is detected, a ``TimeoutError`` is raised.
-
- Raises:
- TimeoutError: If inactivity timeout elapses first.
- DockerError: If container exits prematurely before readiness.
- """
- pattern: Pattern[str] = (
- re.compile(ready_pattern) if isinstance(ready_pattern, str) else ready_pattern
- )
-
- line_queue: Queue[str] = Queue(maxsize=10000)
- stop_event = threading.Event()
- tail = deque(maxlen=capture_tail_lines)
-
- t = threading.Thread(
- target=stream_logs,
- args=(container._container, line_queue, stop_event),
- daemon=True,
- )
- t.start()
-
- # Track time of last log line (or start time if none yet). If we exceed
- # ready_timeout since the last log activity, we fail.
- last_log_time = time.time()
- try:
- while True:
- try:
- line = line_queue.get(timeout=check_interval)
- sanitized = strip_ansi(line)
- tail.append(sanitized)
- logger.info(f"{format_prefix(log_prefix)}{sanitized}")
- if pattern.search(sanitized):
- stop_event.set()
- t.join(timeout=1.0)
- return
- # Update last activity time after processing the line
- last_log_time = time.time()
- except Empty:
- if not container.is_running():
- stop_event.set()
- t.join(timeout=1.0)
- with contextlib.suppress(Exception):
- container._container.remove(force=True)
- raise DockerError("Container exited before readiness was detected.") from None
- # No new line; check inactivity timeout
- if time.time() - last_log_time > ready_timeout:
- stop_event.set()
- t.join(timeout=1.0)
- with contextlib.suppress(Exception):
- container._container.remove(force=True)
- raise TimeoutError(
- "No new container logs received within inactivity timeout before readiness pattern matched."
- ) from None
- finally:
- pass
-
-
-def start_and_wait_ready(
- *,
- image: str,
- name: str | None = None,
- gpus: str | None = None,
- volumes: Mapping[str, str] | None = None,
- ports: Mapping[int, int] | None = None,
- env: Mapping[str, str] | None = None,
- ipc: str | None = None,
- shm_size: str | None = None,
- network: str | None = None,
- user: str | None = None,
- auto_remove: bool = True,
- cmd: Sequence[str] | None = None,
- ready_pattern: (
- str | Pattern[str]
- ) = r"Ready|started|listening|Uvicorn running|Application startup complete",
- ready_timeout: float = 180.0,
- check_interval: float = 0.5,
- log_prefix: str | None = None,
- pull: bool = True,
-) -> DockerContainer:
- """Convenience helper to start a container then wait for readiness.
-
- Note: ``ready_timeout`` is an inactivity timeout (see ``wait_for_readiness``).
- """
- container = start_container(
- image=image,
- name=name,
- gpus=gpus,
- volumes=volumes,
- ports=ports,
- env=env,
- ipc=ipc,
- shm_size=shm_size,
- network=network,
- user=user,
- auto_remove=auto_remove,
- cmd=cmd,
- pull=pull,
- )
- try:
- wait_for_readiness(
- container,
- ready_pattern=ready_pattern,
- ready_timeout=ready_timeout,
- check_interval=check_interval,
- log_prefix=log_prefix,
- )
- except Exception:
- with contextlib.suppress(Exception):
- container.stop()
- raise
- return container
diff --git a/utils/docker/sdk.py b/utils/docker/sdk.py
deleted file mode 100644
index cdcfa7d..0000000
--- a/utils/docker/sdk.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""Low-level Docker SDK access and streaming helpers (internal)."""
-
-from __future__ import annotations
-
-import contextlib
-from queue import Queue
-import threading
-from typing import TYPE_CHECKING
-
-from .errors import DockerError
-
-
-try: # Local import aliasing to avoid hard failure at import time
- import docker # type: ignore
-except Exception as _e: # pragma: no cover - host/env dependent
- docker = None # type: ignore
- _IMPORT_ERROR = _e
-else:
- _IMPORT_ERROR = None
-
-
-if TYPE_CHECKING: # pragma: no cover - typing only
- from docker import DockerClient as _DockerClient
- from docker.models.containers import Container as _DockerContainer
-else: # pragma: no cover - runtime fallback when typing info unavailable
- _DockerClient = object
- _DockerContainer = object
-
-
-def ensure_docker_sdk() -> _DockerClient:
- """Return connected Docker client or raise DockerError with guidance."""
- if _IMPORT_ERROR is not None or docker is None:
- raise DockerError(
- "The 'docker' Python package is required. Install it with: pip install docker"
- ) from _IMPORT_ERROR
- try:
- client = docker.from_env() # type: ignore[union-attr]
- client.ping()
- return client
- except FileNotFoundError as e: # pragma: no cover
- raise DockerError(
- "Could not find the Docker socket. Is the Docker daemon running? "
- "Start it (e.g., 'systemctl start docker' or 'colima start' / 'docker desktop') and retry."
- ) from e
- except PermissionError as e: # pragma: no cover
- raise DockerError(
- "Permission denied accessing the Docker socket. Add your user to the 'docker' group or run with appropriate permissions."
- ) from e
- except Exception as e: # pragma: no cover
- raise DockerError(
- "Failed to connect to Docker daemon via SDK. Ensure the daemon is running."
- ) from e
-
-
-def make_device_requests(gpus: str | None) -> object | None:
- """Translate a CLI-style GPU selection string to SDK device requests.
-
- Supported formats (case-insensitive):
- "all" -> All available GPUs
- "device=0,1" -> Explicit device IDs
- "" -> Request N GPUs (count)
- None / "" -> No GPU scheduling (returns None)
-
- Falls back to requesting all GPUs when an unrecognized non-empty string is provided.
- """
- if not gpus:
- return None
- s = gpus.strip().lower()
- from docker.types import DeviceRequest # type: ignore
-
- if s == "all":
- return [DeviceRequest(count=-1, capabilities=[["gpu"]])]
- if s.startswith("device="):
- ids = s.split("=", 1)[1].strip()
- dev_ids = [part.strip() for part in ids.split(",") if part.strip()]
- return [DeviceRequest(device_ids=dev_ids, capabilities=[["gpu"]])]
- if s.isdigit():
- return [DeviceRequest(count=int(s), capabilities=[["gpu"]])]
- return [DeviceRequest(count=-1, capabilities=[["gpu"]])]
-
-
-def container_is_running(container: _DockerContainer) -> bool:
- """Return True if docker container object status is 'running'."""
- with contextlib.suppress(Exception):
- container.reload()
- return getattr(container, "status", None) == "running"
- return False
-
-
-def stream_logs(
- container: _DockerContainer, line_queue: Queue[str], stop_event: threading.Event
-) -> None:
- """Follow container logs and enqueue decoded lines until stopped."""
- try:
- for chunk in container.logs(stream=True, follow=True):
- if stop_event.is_set():
- break
- try:
- text = chunk.decode("utf-8", errors="replace")
- except Exception:
- text = str(chunk)
- frames = text.split("\r")
- last_frame = frames[-1]
- for ln in last_frame.splitlines():
- line_queue.put(ln)
- except Exception:
- pass
diff --git a/utils/docker/servers.py b/utils/docker/servers.py
deleted file mode 100644
index d59a8be..0000000
--- a/utils/docker/servers.py
+++ /dev/null
@@ -1,177 +0,0 @@
-"""Higher-level helpers for launching model serving runtimes."""
-
-from __future__ import annotations
-
-from collections.abc import Sequence
-import contextlib
-from re import Pattern
-
-from churro.utils.log_utils import logger
-
-from .container import DockerContainer
-from .logging_utils import format_prefix, log_multiline
-from .operations import start_container, wait_for_readiness
-
-
-__all__ = [
- "start_vllm_server",
-]
-
-# Internal helpers (keep local to this module)
-
-
-def _pip_install(
- container: DockerContainer,
- packages: Sequence[str],
- *,
- log_prefix: str | None,
-) -> int:
- """Install pip packages inside the container."""
- if packages:
- # Centralize install logging so callers don't need to emit their own lines.
- joined = " ".join(packages)
- logger.info(f"{format_prefix(log_prefix)}Installing {joined}...")
- cmd: list[str] = [
- "pip",
- "install",
- "--no-input",
- "--root-user-action=ignore",
- *packages,
- ]
- code, out = container.exec(cmd)
- if out:
- log_multiline(out, log_prefix)
- logger.info("")
- if code != 0:
- joined = " ".join(packages) or ""
- logger.warning(
- f"{format_prefix(log_prefix)}Warning: failed to install {joined} (continuing)"
- )
- return code
-
-
-def _get_package_version(
- container: DockerContainer,
- package: str,
- *,
- log_prefix: str | None,
-) -> str:
- """Return package version inside container or 'not installed'."""
- py_code: str = (
- "import importlib,importlib.metadata as m,sys;"
- f"print(m.version('{package}') if importlib.util.find_spec('{package}') else 'not installed')"
- )
- version_line: str | None = None
- for interp in (["python3", "-c", py_code], ["python", "-c", py_code]):
- code, out = container.exec(interp)
- if code == 0 and out:
- version_line = out.strip().splitlines()[-1]
- break
- if version_line is None:
- code, out = container.exec(["pip", "show", package])
- if code == 0 and out:
- for ln in out.splitlines():
- if ln.lower().startswith("version:"):
- version_line = ln.split(":", 1)[1].strip()
- break
- version_line = version_line or "not installed"
- log_multiline(version_line, log_prefix)
- return version_line
-
-
-def start_vllm_server(
- *,
- model: str,
- served_model_name: str | None = None,
- host_port: int = 9000,
- container_port: int = 8000,
- gpus: str = "all",
- data_parallel_size: int = 1,
- tensor_parallel_size: int = 1,
- gpu_memory_utilization: float = 0.9,
- max_model_len: int | None = None,
- huggingface_cache: str = "~/.cache/huggingface",
- image: str = "vllm/vllm-openai:v0.11.0",
- model_args: Sequence[str] | None = None,
- ready_timeout: float = 180.0,
- ready_pattern: (str | Pattern[str]) = r"Application startup complete|Uvicorn running on http",
- log_prefix: str | None = "[vLLM] ",
- force_replace: bool = False,
- install_flash_attn: bool = False,
-) -> DockerContainer:
- """Start a vLLM OpenAI-compatible API container and wait for readiness."""
- volumes: dict[str, str] = {huggingface_cache: "/root/.cache/huggingface"}
- ports: dict[int, int] = {host_port: container_port}
-
- logger.info(f"{format_prefix(log_prefix)}Data parallel size set to {data_parallel_size}")
- logger.info(f"{format_prefix(log_prefix)}Tensor parallel size set to {tensor_parallel_size}")
-
- cmd: list[str] = [
- "--model",
- model,
- "--gpu_memory_utilization",
- str(gpu_memory_utilization),
- "--data-parallel-size",
- str(data_parallel_size),
- "--trust_remote_code",
- "--tensor-parallel-size",
- str(tensor_parallel_size),
- ]
- if max_model_len is not None:
- cmd += ["--max-model-len", str(max_model_len)]
- if served_model_name:
- cmd += ["--served-model-name", served_model_name]
- if model_args:
- cmd += list(model_args)
-
- base_image_part: str = image.rsplit("/", 1)[-1]
- base_no_tag: str = base_image_part.split(":", 1)[0]
- sanitized_name: str = base_no_tag.replace("/", "-").replace(":", "-") or "vllm"
-
- logger.info(f"Starting vLLM container '{sanitized_name}'. This may take several minutes...")
-
- container = start_container(
- image=image,
- name=sanitized_name,
- gpus=gpus,
- volumes=volumes,
- ports=ports,
- ipc="host",
- cmd=cmd,
- force_replace=force_replace,
- pull=True,
- )
-
- if "mistral" in model.lower():
- # Mistral models need this package
- _pip_install(
- container,
- ["mistral-common==1.8.4"],
- log_prefix=log_prefix,
- )
- logger.info(f"{format_prefix(log_prefix)}mistral_common version:")
- _get_package_version(container, "mistral_common", log_prefix=log_prefix)
-
- _pip_install(container, ["open_clip_torch"], log_prefix=log_prefix)
- logger.info(f"{format_prefix(log_prefix)}open_clip_torch version:")
- _get_package_version(container, "open_clip_torch", log_prefix=log_prefix)
-
- if install_flash_attn:
- # install flash-attn
- _pip_install(container, ["flash-attn"], log_prefix=log_prefix)
- logger.info(f"{format_prefix(log_prefix)}flash-attn version:")
- _get_package_version(container, "flash-attn", log_prefix=log_prefix)
-
- try:
- wait_for_readiness(
- container,
- ready_pattern=ready_pattern,
- ready_timeout=ready_timeout,
- check_interval=1.0,
- log_prefix=log_prefix,
- )
- except Exception:
- with contextlib.suppress(Exception):
- container.stop()
- raise
- return container
diff --git a/utils/docker/vllm.py b/utils/docker/vllm.py
deleted file mode 100644
index 1695ac9..0000000
--- a/utils/docker/vllm.py
+++ /dev/null
@@ -1,138 +0,0 @@
-"""Helpers for conditionally starting local vLLM servers for OCR pipelines.
-
-Central responsibilities:
- * Inspect the `MODEL_MAP` for vLLM-backed engine entries.
- * Resolve the appropriate Hugging Face repository for a hosted variant.
- * Conditionally launch a local vLLM Docker container when an OCR run
- requires a locally served model (provider model name starts with ``vllm/``).
-
-Public entrypoints:
- - has_at_least_one_vllm(engine_key)
- - get_hf_repo_for_hosted(engine_key)
- - maybe_start_vllm_server_for_engine(args)
-
-Functions accept lightweight, explicit parameters (or an `argparse.Namespace`-like
-object) to avoid importing OCR-specific code here.
-"""
-
-from __future__ import annotations
-
-from churro.config import ChurroSettings, get_settings as get_churro_settings
-from churro.utils.llm.models import MODEL_MAP
-from churro.utils.llm.types import ModelInfo
-from churro.utils.log_utils import logger
-
-from . import DockerContainer, start_vllm_server
-
-
-__all__ = [
- "get_hf_repo_for_hosted",
- "has_at_least_one_vllm",
- "maybe_start_vllm_server_for_engine",
-]
-
-
-def get_hf_repo_for_hosted(engine_key: str) -> str | None:
- """Return the first Hugging Face repo associated with a vLLM provider model.
-
- Iterates over model info entries for ``engine_key`` inside ``MODEL_MAP`` and
- returns the first non-empty ``hf_repo`` whose ``provider_model`` starts with
- ``"vllm/"``.
- """
- infos: list[ModelInfo] | None = MODEL_MAP.get(engine_key)
- if not infos:
- return None
- for info in infos:
- provider_model = info["provider_model"]
- if provider_model.startswith("vllm/"):
- repo = info.get("hf_repo") # optional
- if repo:
- return repo
- return None
-
-
-def has_at_least_one_vllm(engine_key: str) -> bool:
- """Return True if any provider model variant for the engine uses vLLM."""
- infos: list[ModelInfo] | None = MODEL_MAP.get(engine_key)
- if not infos:
- return False
- return any(info["provider_model"].startswith("vllm/") for info in infos)
-
-
-def _select_model_repo(engine: str) -> str:
- """Return the backing HF repo for a vLLM-hosted engine or raise."""
- model_repo = get_hf_repo_for_hosted(engine)
- if model_repo is None:
- raise ValueError(
- f"Engine '{engine}' expected to have a vLLM-backed hf_repo but none was found."
- )
- return model_repo
-
-
-def maybe_start_vllm_server_for_engine(
- *,
- engine: str | None,
- system: str,
- tensor_parallel_size: int = 1,
- data_parallel_size: int = 1,
- log_prefix: str | None = None,
- install_flash_attn: bool = False,
- settings: ChurroSettings | None = None,
-) -> DockerContainer | None:
- """Conditionally start a vLLM server for the provided engine.
-
- Side effects (container launch) only occur when all of the following are true:
- * system in {"llm", "finetuned"}
- * engine provided and corresponds to at least one vLLM provider variant
-
- Args:
- engine: Logical engine key (MODEL_MAP) to potentially serve.
- system: OCR system type (e.g., 'llm', 'finetuned').
- tensor_parallel_size: Tensor parallel degree for vLLM container.
- data_parallel_size: Data parallel degree for vLLM container.
- log_prefix: Optional log prefix string.
- install_flash_attn: Whether to attempt to install flash attention
- support inside the container.
- settings: Optional pre-loaded configuration snapshot. Supplying this allows
- callers (including tests) to inject custom ports or tokens without
- mutating global environment variables.
-
- Returns:
- DockerContainer | None: Container object if launched else None.
- """
- if not (system in {"llm", "finetuned"} and engine):
- return None
-
- if not has_at_least_one_vllm(engine):
- return None
-
- config = settings or get_churro_settings()
- host_port = config.local.vllm_port
- if host_port is None:
- raise ValueError("LOCAL_VLLM_PORT must be set in environment to start local vLLM server.")
- if not (0 < host_port < 65536):
- raise ValueError(f"LOCAL_VLLM_PORT value out of range: {host_port}")
-
- model_repo = _select_model_repo(engine)
-
- logger.info(
- f"{log_prefix or ''} Starting local vLLM server for engine '{engine}' with HF repo '{model_repo}'."
- )
-
- max_model_len = None
- engine_infos: list[ModelInfo] | None = MODEL_MAP.get(engine)
- if engine_infos:
- first = engine_infos[0]
- max_model_len = first["max_completion_tokens"]
-
- container = start_vllm_server(
- model=model_repo,
- served_model_name=engine,
- host_port=host_port,
- max_model_len=max_model_len,
- force_replace=True,
- tensor_parallel_size=tensor_parallel_size,
- data_parallel_size=data_parallel_size,
- install_flash_attn=install_flash_attn,
- )
- return container
diff --git a/utils/image/binarizer.py b/utils/image/binarizer.py
deleted file mode 100644
index f98472e..0000000
--- a/utils/image/binarizer.py
+++ /dev/null
@@ -1,793 +0,0 @@
-"""Deep-learning-based image binarization utility.
-
-This module wraps the Eynollah binarization network exported to ONNX. The model is described at
-https://dl.acm.org/doi/10.1145/3604951.3605513, and weights are fetched from the Hugging Face
-Hub on first use.
-"""
-
-from __future__ import annotations
-
-from collections.abc import Sequence
-from dataclasses import dataclass
-import os
-from pathlib import Path
-
-import numpy as np
-import onnxruntime as ort
-from PIL import Image, ImageOps
-
-from churro.utils.log_utils import logger
-
-
-DEFAULT_REPO_ID = "stanford-oval/eynollah_binarizer_onnx"
-DEFAULT_ONNX_FILENAME = "eynollah_binarizer.onnx"
-MODEL_HEIGHT = 224
-MODEL_WIDTH = 448
-DEFAULT_PROVIDERS = ("CPUExecutionProvider",)
-GPU_PREFERRED_PROVIDERS: tuple[str, ...] = (
- "CUDAExecutionProvider",
- "ROCMExecutionProvider",
- "DmlExecutionProvider",
-)
-
-
-@dataclass(slots=True)
-class _PredictBatchContext:
- """Book-keeping for trimming predictions back to original image dimensions."""
-
- original_shape: tuple[int, int]
- index_start_h: int
- index_start_w: int
-
-
-@dataclass(slots=True)
-class _PatchBatchContext:
- """Batch-level state for reconstructing tiled predictions."""
-
- image: np.ndarray
- prediction: np.ndarray
- nxf: int
- nyf: int
-
-
-@dataclass(slots=True)
-class _PatchMetadata:
- """Metadata describing a single tiled patch in a batched inference run."""
-
- image_index: int
- i: int
- j: int
- index_x_d: int
- index_x_u: int
- index_y_d: int
- index_y_u: int
-
-
-class ImageBinarizer:
- """High-level wrapper around the ONNX-exported Eynollah binarizer model.
-
- The class encapsulates model discovery, execution provider selection, and helpers for
- tiling oversized images into overlapping patches so that the ONNX Runtime session can
- process arbitrary page sizes. Public helpers are provided for both numpy arrays and
- Pillow images to match the data formats used throughout the HistoryGenie pipelines.
- """
-
- def __init__(
- self,
- repo_id: str = DEFAULT_REPO_ID,
- filename: str = DEFAULT_ONNX_FILENAME,
- providers: Sequence[str] | None = None,
- max_patch_batch_size: int | None = 8,
- ) -> None:
- """Instantiate the binarizer and prepare the ONNX Runtime session.
-
- Args:
- repo_id: Hugging Face Hub repository that stores the ONNX artifacts.
- filename: Model filename relative to the repository snapshot.
- providers: Optional explicit list of ONNX Runtime execution providers.
- When ``None`` (default) the runtime will prefer GPU providers if available.
- max_patch_batch_size: Optional upper bound on the number of tiled patches
- processed per inference call. ``None`` means unbounded, otherwise values
- must be positive.
-
- Raises:
- ValueError: If ``max_patch_batch_size`` is provided but evaluates to < 1.
- FileNotFoundError: If the requested ``filename`` cannot be located in the
- resolved repository snapshot.
- """
- self.model_height = MODEL_HEIGHT
- self.model_width = MODEL_WIDTH
- if max_patch_batch_size is not None and max_patch_batch_size < 1:
- raise ValueError("max_patch_batch_size must be >= 1 when provided")
- self._max_patch_batch_size = max_patch_batch_size
-
- artifacts_dir = self._ensure_artifacts_dir()
- onnx_path = self._resolve_model_path(artifacts_dir, repo_id, filename)
-
- session_providers = tuple(providers) if providers else self._select_execution_providers()
- self.session = self._create_session(onnx_path, session_providers)
- self.input_name = self.session.get_inputs()[0].name
-
- def _effective_patch_batch_size(self, requested: int) -> int:
- """Clamp a requested patch batch size to the configured safe interval.
-
- Args:
- requested: Raw patch batch size requested by the caller.
-
- Returns:
- int: Value guaranteed to be at least 1 and, if configured, not exceed
- ``max_patch_batch_size``.
- """
- safe = max(1, requested)
- if self._max_patch_batch_size is not None:
- safe = min(safe, self._max_patch_batch_size)
- return safe
-
- def _select_execution_providers(self) -> tuple[str, ...]:
- """Choose the best available ONNX Runtime execution providers.
-
- The method prefers GPU accelerators when present and logs the final selection.
-
- Returns:
- tuple[str, ...]: Providers passed to ``onnxruntime.InferenceSession``.
- """
- available = set(ort.get_available_providers())
- if not available:
- logger.warning("No ONNX Runtime execution providers detected; defaulting to CPU.")
- chosen = DEFAULT_PROVIDERS
- else:
- for provider in GPU_PREFERRED_PROVIDERS:
- if provider in available:
- chosen = (provider, "CPUExecutionProvider")
- break
- else:
- if DEFAULT_PROVIDERS[0] in available:
- chosen = DEFAULT_PROVIDERS
- else:
- logger.warning(
- "Preferred ONNX Runtime providers unavailable; using first detected provider instead."
- )
- # Preserve deterministic order when falling back to arbitrary providers
- chosen = tuple(sorted(available))
-
- logger.info(f"Configured ONNX Runtime providers: {chosen}")
- return chosen
-
- def _predict(self, img: np.ndarray, n_batch_inference: int) -> np.ndarray:
- """Predict a binarization mask for a single image.
-
- Args:
- img: HxWxC image array in RGB order with values in ``[0, 255]``.
- n_batch_inference: Target mini-batch size used for tiled inference.
-
- Returns:
- np.ndarray: Binary class prediction aligned with ``img``.
- """
- predictions = self._predict_batch((img,), n_batch_inference)
- return predictions[0]
-
- def _predict_batch(
- self, imgs: Sequence[np.ndarray], n_batch_inference: int
- ) -> list[np.ndarray]:
- """Predict binarization masks for a sequence of images.
-
- Args:
- imgs: Iterable of HxWxC RGB arrays.
- n_batch_inference: Target mini-batch size forwarded to tiled inference.
-
- Returns:
- list[np.ndarray]: Per-image binary masks with original shapes preserved.
-
- Raises:
- ValueError: If any image does not have three dimensions.
- """
- if not imgs:
- return []
-
- contexts: list[_PredictBatchContext] = []
- padded_images: list[np.ndarray] = []
- for img in imgs:
- if img.ndim != 3:
- raise ValueError("Images must be HxWxC arrays.")
-
- img_org_h, img_org_w = img.shape[:2]
- img_padded, index_start_h, index_start_w = self._pad_image(
- img, self.model_height, self.model_width
- )
-
- contexts.append(
- _PredictBatchContext(
- original_shape=(img_org_h, img_org_w),
- index_start_h=index_start_h,
- index_start_w=index_start_w,
- )
- )
- padded_images.append(img_padded)
-
- effective_batch = self._effective_patch_batch_size(n_batch_inference)
- predictions = self._predict_with_patches_batch(
- padded_images, self.model_height, self.model_width, effective_batch
- )
-
- trimmed: list[np.ndarray] = []
- for prediction, ctx in zip(predictions, contexts, strict=False):
- index_start_h = ctx.index_start_h
- index_start_w = ctx.index_start_w
- orig_h, orig_w = ctx.original_shape
- cropped = prediction[
- index_start_h : index_start_h + orig_h,
- index_start_w : index_start_w + orig_w,
- ]
- trimmed.append(cropped.astype(np.uint8))
-
- return trimmed
-
- @staticmethod
- def _ensure_artifacts_dir() -> Path:
- """Return the cache directory that stores downloaded ONNX artifacts.
-
- Returns:
- Path: Fully resolved path to the ``artifacts`` folder located next to this
- module. The directory is created if it does not yet exist.
- """
- artifacts_dir = Path(__file__).resolve().parent / "artifacts"
- artifacts_dir.mkdir(parents=True, exist_ok=True)
- return artifacts_dir
-
- @staticmethod
- def _create_session(model_path: Path, providers: Sequence[str]) -> ort.InferenceSession:
- """Configure ONNX Runtime and create an inference session.
-
- Args:
- model_path: Location of the ONNX file on disk.
- providers: Ordered execution providers to supply to ONNX Runtime.
-
- Returns:
- onnxruntime.InferenceSession: Ready-to-run inference session.
- """
- options = ort.SessionOptions()
- options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
- options.intra_op_num_threads = max(1, os.cpu_count() or 1)
- return ort.InferenceSession(
- str(model_path),
- sess_options=options,
- providers=list(providers),
- )
-
- def _resolve_model_path(self, artifacts_dir: Path, repo_id: str, filename: str) -> Path:
- """Return the path to the ONNX model, downloading it if absent.
-
- Args:
- artifacts_dir: Directory used for storing fetched artifacts.
- repo_id: Hugging Face Hub repository to search.
- filename: Expected ONNX file name within ``repo_id``.
-
- Returns:
- Path: Resolved path to the local ONNX model.
-
- Raises:
- FileNotFoundError: If ``filename`` is missing from the downloaded snapshot.
- """
- local_onnx_path = artifacts_dir / filename
- if local_onnx_path.exists():
- return local_onnx_path
-
- snapshot_path = self._download_snapshot(repo_id)
- candidate_path = snapshot_path / filename
- if not candidate_path.exists():
- raise FileNotFoundError(
- f"File {filename} not found in snapshot for repo {repo_id}. "
- "Ensure the ONNX artifact has been uploaded."
- )
- return candidate_path
-
- @staticmethod
- def _download_snapshot(repo_id: str) -> Path:
- """Download a Hugging Face Hub snapshot with local cache fallback.
-
- Args:
- repo_id: Hugging Face repository identifier.
-
- Returns:
- Path: Local cache directory containing the repository snapshot.
- """
- from huggingface_hub import snapshot_download
- from huggingface_hub.utils import LocalEntryNotFoundError
-
- try:
- return Path(snapshot_download(repo_id=repo_id, local_files_only=True))
- except LocalEntryNotFoundError:
- snapshot_path = Path(snapshot_download(repo_id=repo_id))
- logger.info(
- f"Downloaded ONNX model from Hugging Face Hub repo {repo_id} to cache {snapshot_path}"
- )
- return snapshot_path
-
- @staticmethod
- def _pad_image(
- img: np.ndarray, model_height: int, model_width: int
- ) -> tuple[np.ndarray, int, int]:
- """Pad an image symmetrically until it matches the model receptive field.
-
- Args:
- img: HxWxC image to be padded.
- model_height: Minimum height expected by the ONNX model.
- model_width: Minimum width expected by the ONNX model.
-
- Returns:
- tuple[np.ndarray, int, int]: Padded image plus the top-left indices at which
- the original pixels begin inside the padded array.
- """
- img_h, img_w = img.shape[:2]
- channels = img.shape[2]
-
- if img_h < model_height and img_w >= model_width:
- img_padded = np.zeros((model_height, img_w, channels), dtype=img.dtype)
- index_start_h = int(abs(img_h - model_height) / 2.0)
- index_start_w = 0
- img_padded[index_start_h : index_start_h + img_h, :, :] = img
-
- elif img_h >= model_height and img_w < model_width:
- img_padded = np.zeros((img_h, model_width, channels), dtype=img.dtype)
- index_start_h = 0
- index_start_w = int(abs(img_w - model_width) / 2.0)
- img_padded[:, index_start_w : index_start_w + img_w, :] = img
-
- elif img_h < model_height and img_w < model_width:
- img_padded = np.zeros((model_height, model_width, channels), dtype=img.dtype)
- index_start_h = int(abs(img_h - model_height) / 2.0)
- index_start_w = int(abs(img_w - model_width) / 2.0)
- img_padded[
- index_start_h : index_start_h + img_h,
- index_start_w : index_start_w + img_w,
- :,
- ] = img
- else:
- index_start_h = 0
- index_start_w = 0
- img_padded = np.copy(img)
-
- return img_padded, index_start_h, index_start_w
-
- @staticmethod
- def _resize_numpy_image(
- image: np.ndarray,
- *,
- scale: float | None = None,
- size: tuple[int, int] | None = None,
- resample: int = Image.Resampling.LANCZOS,
- ) -> np.ndarray:
- """Resize a numpy-backed image using Pillow as the backend.
-
- Args:
- image: Input array with shape HxWxC or HxW.
- scale: Optional multiplicative factor applied to width and height.
- size: Optional explicit output size as (width, height). When provided it
- overrides ``scale``.
- resample: Pillow resampling filter to use.
-
- Returns:
- np.ndarray: Resized array cast back to the original dtype when possible.
-
- Raises:
- ValueError: If neither ``scale`` nor ``size`` are provided.
- TypeError: If ``image`` is not a NumPy array or cannot be coerced to one.
- """
- if scale is None and size is None:
- raise ValueError("Either scale or size must be provided for resizing.")
-
- if not isinstance(image, np.ndarray):
- raise TypeError("image must be a numpy.ndarray")
-
- if size is None:
- height, width = image.shape[:2]
- target_width = max(1, int(round(width * scale)))
- target_height = max(1, int(round(height * scale)))
- size = (target_width, target_height)
-
- array = image
- original_dtype = image.dtype
- unit_range_float = False
- if array.dtype != np.uint8:
- if np.issubdtype(array.dtype, np.floating):
- scaled = array
- if scaled.size:
- max_value = float(np.max(scaled))
- else: # pragma: no cover - empty guard
- max_value = 0.0
- if max_value <= 1.0:
- scaled = scaled * 255.0
- unit_range_float = True
- array = np.clip(scaled, 0, 255).astype(np.uint8)
- else:
- array = np.clip(array, 0, 255).astype(np.uint8)
-
- try:
- pil_image = Image.fromarray(array)
- except (TypeError, ValueError) as exc: # pragma: no cover - defensive guard
- raise TypeError("Unsupported image format for resizing.") from exc
-
- resized = pil_image.resize(size, resample=resample)
- resized_array = np.asarray(resized)
-
- if original_dtype == np.uint8:
- return resized_array
-
- if np.issubdtype(original_dtype, np.floating):
- if unit_range_float:
- resized_array = resized_array.astype(np.float32) / 255.0
- else:
- resized_array = resized_array.astype(np.float32)
- return resized_array.astype(original_dtype, copy=False)
-
- return resized_array.astype(original_dtype, copy=False)
-
- def _predict_with_patches(
- self,
- img: np.ndarray,
- model_height: int,
- model_width: int,
- n_batch_inference: int,
- ) -> np.ndarray:
- """Predict a mask by tiling a single image into overlapping patches.
-
- Args:
- img: HxWxC RGB array to process.
- model_height: Patch height consumed by the ONNX model.
- model_width: Patch width consumed by the ONNX model.
- n_batch_inference: Preferred batch size for forwarding patches.
-
- Returns:
- np.ndarray: Binary mask with the same height and width as ``img``.
-
- Raises:
- ValueError: If derived patch dimensions would be non-positive.
- """
- margin = int(0.1 * model_width)
- width_mid = model_width - 2 * margin
- height_mid = model_height - 2 * margin
- if width_mid <= 0 or height_mid <= 0:
- raise ValueError(
- "Computed patch dimensions must be positive; check model size and margin."
- )
-
- img_h, img_w = img.shape[:2]
-
- prediction_true = np.zeros((img_h, img_w), dtype=np.uint8)
-
- nxf = int(np.ceil(img_w / width_mid))
- nyf = int(np.ceil(img_h / height_mid))
-
- n_batch_inference = self._effective_patch_batch_size(n_batch_inference)
- channels = img.shape[2]
- batch_buffer = np.empty(
- (n_batch_inference, model_height, model_width, channels), dtype=np.float32
- )
- batch_metadata: list[tuple[int, int, int, int, int, int]] = []
- current_batch_size = 0
-
- def flush_batch() -> None:
- nonlocal current_batch_size
- if current_batch_size == 0:
- return
-
- batch = batch_buffer[:current_batch_size]
- outputs = self.session.run(None, {self.input_name: batch})
- batch_seg = np.argmax(np.asarray(outputs[0], dtype=np.float32), axis=-1).astype(
- np.uint8
- )
-
- h = self.model_height
- w = self.model_width
-
- for seg_patch, meta in zip(batch_seg, batch_metadata, strict=False):
- i, j, index_x_d, _index_x_u, index_y_d, _index_y_u = meta
-
- if nyf == 1:
- crop_y_start = 0
- crop_y_end = h
- else:
- crop_y_start = 0 if j == 0 else margin
- crop_y_end = h if j == nyf - 1 else h - margin
-
- if nxf == 1:
- crop_x_start = 0
- crop_x_end = w
- else:
- crop_x_start = 0 if i == 0 else margin
- crop_x_end = w if i == nxf - 1 else w - margin
-
- y_start = index_y_d + crop_y_start
- y_end = index_y_d + crop_y_end
- x_start = index_x_d + crop_x_start
- x_end = index_x_d + crop_x_end
-
- prediction_true[y_start:y_end, x_start:x_end] = seg_patch[
- crop_y_start:crop_y_end, crop_x_start:crop_x_end
- ]
-
- batch_metadata.clear()
- current_batch_size = 0
-
- num_patches = 0
- for i in range(nxf):
- for j in range(nyf):
- index_x_d = i * width_mid
- index_x_u = min(index_x_d + model_width, img_w)
- if index_x_u == img_w:
- index_x_d = img_w - model_width
-
- index_y_d = j * height_mid
- index_y_u = min(index_y_d + model_height, img_h)
- if index_y_u == img_h:
- index_y_d = img_h - model_height
-
- np.multiply(
- img[index_y_d:index_y_u, index_x_d:index_x_u, :],
- 1.0 / 255.0,
- out=batch_buffer[current_batch_size],
- casting="unsafe",
- )
- batch_metadata.append((i, j, index_x_d, index_x_u, index_y_d, index_y_u))
- num_patches += 1
- current_batch_size += 1
-
- if current_batch_size == n_batch_inference:
- flush_batch()
-
- flush_batch()
- logger.debug(f"Split image into {num_patches} patches for binarization.")
-
- return prediction_true
-
- def _predict_with_patches_batch(
- self,
- imgs: Sequence[np.ndarray],
- model_height: int,
- model_width: int,
- n_batch_inference: int,
- ) -> list[np.ndarray]:
- """Predict masks for many images by tiling them into overlapping patches.
-
- Args:
- imgs: Iterable of HxWxC RGB arrays.
- model_height: Patch height consumed by the ONNX model.
- model_width: Patch width consumed by the ONNX model.
- n_batch_inference: Preferred patch batch size for inference calls.
-
- Returns:
- list[np.ndarray]: Binary masks aligned with each input image.
-
- Raises:
- ValueError: If derived patch dimensions would be non-positive or images have
- differing channel counts.
- """
- if not imgs:
- return []
-
- margin = int(0.1 * model_width)
- width_mid = model_width - 2 * margin
- height_mid = model_height - 2 * margin
- if width_mid <= 0 or height_mid <= 0:
- raise ValueError(
- "Computed patch dimensions must be positive; check model size and margin."
- )
-
- channels = imgs[0].shape[2]
- for img in imgs:
- if img.shape[2] != channels:
- raise ValueError("All images must have the same channel count.")
-
- contexts: list[_PatchBatchContext] = []
- for img in imgs:
- img_h, img_w = img.shape[:2]
- contexts.append(
- _PatchBatchContext(
- image=img,
- prediction=np.zeros((img_h, img_w), dtype=np.uint8),
- nxf=int(np.ceil(img_w / width_mid)),
- nyf=int(np.ceil(img_h / height_mid)),
- )
- )
-
- n_batch_inference = self._effective_patch_batch_size(n_batch_inference)
- batch_buffer = np.empty(
- (n_batch_inference, model_height, model_width, channels), dtype=np.float32
- )
- batch_metadata: list[_PatchMetadata] = []
- current_batch_size = 0
-
- def flush_batch() -> None:
- nonlocal current_batch_size
- if current_batch_size == 0:
- return
-
- batch = batch_buffer[:current_batch_size]
- outputs = self.session.run(None, {self.input_name: batch})
- batch_seg = np.argmax(np.asarray(outputs[0], dtype=np.float32), axis=-1).astype(
- np.uint8
- )
-
- for seg_patch, meta in zip(batch_seg, batch_metadata, strict=False):
- context = contexts[meta.image_index]
- prediction = context.prediction
- nxf = context.nxf
- nyf = context.nyf
-
- if nyf == 1:
- crop_y_start = 0
- crop_y_end = model_height
- else:
- crop_y_start = 0 if meta.j == 0 else margin
- crop_y_end = model_height if meta.j == nyf - 1 else model_height - margin
-
- if nxf == 1:
- crop_x_start = 0
- crop_x_end = model_width
- else:
- crop_x_start = 0 if meta.i == 0 else margin
- crop_x_end = model_width if meta.i == nxf - 1 else model_width - margin
-
- y_start = meta.index_y_d + crop_y_start
- y_end = meta.index_y_d + crop_y_end
- x_start = meta.index_x_d + crop_x_start
- x_end = meta.index_x_d + crop_x_end
-
- prediction[y_start:y_end, x_start:x_end] = seg_patch[
- crop_y_start:crop_y_end, crop_x_start:crop_x_end
- ]
-
- batch_metadata.clear()
- current_batch_size = 0
-
- total_patches = 0
- for img_idx, context in enumerate(contexts):
- img = context.image
- img_h, img_w = img.shape[:2]
- nxf = context.nxf
- nyf = context.nyf
-
- for i in range(nxf):
- for j in range(nyf):
- index_x_d = i * width_mid
- index_x_u = min(index_x_d + model_width, img_w)
- if index_x_u == img_w:
- index_x_d = img_w - model_width
-
- index_y_d = j * height_mid
- index_y_u = min(index_y_d + model_height, img_h)
- if index_y_u == img_h:
- index_y_d = img_h - model_height
-
- np.multiply(
- img[index_y_d:index_y_u, index_x_d:index_x_u, :],
- 1.0 / 255.0,
- out=batch_buffer[current_batch_size],
- casting="unsafe",
- )
- batch_metadata.append(
- _PatchMetadata(
- image_index=img_idx,
- i=i,
- j=j,
- index_x_d=index_x_d,
- index_x_u=index_x_u,
- index_y_d=index_y_d,
- index_y_u=index_y_u,
- )
- )
- current_batch_size += 1
- total_patches += 1
-
- if current_batch_size == n_batch_inference:
- flush_batch()
-
- flush_batch()
- logger.debug(
- f"Split {len(imgs)} images into {total_patches} patches for batched binarization."
- )
- return [context.prediction for context in contexts]
-
- def _binarize_numpy_batch(
- self, images: Sequence[np.ndarray], scale: float = 1.0, n_batch_inference: int = 16
- ) -> list[np.ndarray]:
- """Binarize many numpy-backed images, batching patches across the group.
-
- Args:
- images: Sequence of HxWxC RGB arrays to binarize.
- scale: Optional scaling factor applied prior to inference for all images.
- n_batch_inference: Preferred patch batch size for tiled inference.
-
- Returns:
- list[np.ndarray]: Binary masks with uint8 dtype where foreground is 0 and
- background is 255.
- """
- if not images:
- return []
-
- working_images: list[np.ndarray] = []
- original_shapes: list[tuple[int, int]] = []
- scale_factors: list[bool] = []
- for image in images:
- original_shapes.append(image.shape[:2])
- if np.isclose(scale, 1.0):
- working_images.append(image)
- scale_factors.append(False)
- else:
- resized = self._resize_numpy_image(image, scale=scale)
- working_images.append(resized)
- scale_factors.append(True)
-
- predictions = self._predict_batch(working_images, n_batch_inference=n_batch_inference)
-
- binaries: list[np.ndarray] = []
- for prediction, original_shape, needs_rescale in zip(
- predictions, original_shapes, scale_factors, strict=False
- ):
- binary_image = np.where(prediction == 0, 255, 0).astype(np.uint8)
- if needs_rescale:
- binary_image = self._resize_numpy_image(
- binary_image,
- size=(original_shape[1], original_shape[0]),
- )
- binaries.append(binary_image)
-
- return binaries
-
- def binarize_pil_batch(
- self, images: Sequence[Image.Image], scale: float = 1.0, n_batch_inference: int = 16
- ) -> list[Image.Image]:
- """Binarize many Pillow images and return outputs in mode ``L``.
-
- Args:
- images: Sequence of ``PIL.Image.Image`` instances to process.
- scale: Optional scaling factor applied prior to inference.
- n_batch_inference: Preferred patch batch size for tiled inference.
-
- Returns:
- list[Image.Image]: Binarized images in single-channel (``L``) mode.
-
- Raises:
- TypeError: If any element of ``images`` is not a Pillow image.
- """
- if not images:
- return []
-
- normalized: list[Image.Image] = []
- for image in images:
- if not isinstance(image, Image.Image):
- raise TypeError("images must be instances of PIL.Image.Image")
-
- try:
- image = ImageOps.exif_transpose(image)
- except Exception as exc: # pragma: no cover - defensive guard
- logger.debug(f"Failed to normalize EXIF orientation: {exc}")
-
- if image.mode != "RGB":
- image = image.convert("RGB")
-
- normalized.append(image)
-
- np_images = [np.asarray(img) for img in normalized]
- binary_arrays = self._binarize_numpy_batch(
- np_images, scale=scale, n_batch_inference=n_batch_inference
- )
- return [Image.fromarray(arr, mode="L") for arr in binary_arrays]
-
- def binarize_pil(
- self, image: Image.Image, scale: float = 1.0, n_batch_inference: int = 16
- ) -> Image.Image:
- """Binarize a Pillow image and return the result in mode ``L``.
-
- Args:
- image: Pillow image to process.
- scale: Optional scaling factor applied prior to inference.
- n_batch_inference: Preferred patch batch size for tiled inference.
-
- Returns:
- PIL.Image.Image: Binarized single-channel output.
- """
- results = self.binarize_pil_batch(
- (image,), scale=scale, n_batch_inference=n_batch_inference
- )
- return results[0]
diff --git a/utils/image/io.py b/utils/image/io.py
deleted file mode 100644
index 45d9792..0000000
--- a/utils/image/io.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""Async image loading helpers."""
-
-from __future__ import annotations
-
-from io import BytesIO
-from pathlib import Path
-
-import aiofiles
-from PIL import Image
-
-
-async def load_image_async(image_path: str | Path) -> Image.Image:
- """Load an image from disk without blocking the event loop."""
- path = Path(image_path)
- async with aiofiles.open(path, "rb") as file_obj:
- image_bytes = await file_obj.read()
- image = Image.open(BytesIO(image_bytes))
- image.load()
- return image
-
-
-__all__ = ["load_image_async"]
diff --git a/utils/image/transform.py b/utils/image/transform.py
deleted file mode 100644
index 571515e..0000000
--- a/utils/image/transform.py
+++ /dev/null
@@ -1,85 +0,0 @@
-import numpy as np
-from PIL import Image, ImageOps
-from skimage.filters import threshold_sauvola
-from skimage.util import img_as_ubyte
-
-from churro.page.page import Page
-from churro.utils.log_utils import logger
-
-
-def resize_image_to_fit(image: Image.Image, max_width: int, max_height: int) -> Image.Image:
- """Resize to fit inside (``max_width``, ``max_height``) maintaining aspect ratio if larger."""
- original_width, original_height = image.size
-
- # Only scale if the image is larger than the max dimensions
- if original_width <= max_width and original_height <= max_height:
- return image
-
- scale = min(max_width / original_width, max_height / original_height)
- new_size = (int(original_width * scale), int(original_height * scale))
- return image.resize(new_size, resample=Image.LANCZOS) # type: ignore
-
-
-def adjust_image(
- image: Image.Image,
- thresholding: bool = False,
-) -> Image.Image:
- """Preprocess a scanned newspaper image for OCR without OpenCV.
-
- Steps:
- 0. Normalize orientation via EXIF metadata (Pillow).
- 1. Convert to grayscale (Pillow).
- 2. (Optional) Apply Sauvola threshold (skimage).
- 3. Convert back to original mode (e.g. RGB) for downstream consistency.
- """
- # Apply EXIF-based rotation once so saved outputs keep the expected orientation
- image = ImageOps.exif_transpose(image)
- original_mode = image.mode
-
- # Grayscale via Pillow
- gray_image = image.convert("L")
-
- if thresholding:
- gray_arr = np.array(gray_image)
- # Sauvola thresholding
- thresh = threshold_sauvola(gray_arr, window_size=15, k=0.2)
- binarized = img_as_ubyte(gray_arr > thresh)
- gray_image = Image.fromarray(binarized)
-
- # Restore original mode if needed
- if gray_image.mode != original_mode:
- gray_image = gray_image.convert(original_mode)
-
- return gray_image
-
-
-def rotate_image_and_page(image: Image.Image, angle: float, page: Page) -> tuple[Image.Image, Page]:
- if angle == 0.0:
- return image, page
-
- if abs(angle) > 3.0:
- logger.info(f"Rotating page by {angle:.1f} degrees")
-
- original_width, original_height = image.size
-
- # Rotate the image
- rotated_image = image.rotate(
- angle, resample=Image.Resampling.BICUBIC, expand=True, fillcolor=(255, 255, 255)
- )
- rotated_width, rotated_height = rotated_image.size
-
- # Calculate the offset caused by the expansion
- offset_x = (rotated_width - original_width) / 2
- offset_y = (rotated_height - original_height) / 2
-
- # Get the center of the original image
- center = (original_width / 2, original_height / 2)
-
- page_copy = page.model_copy(deep=True)
- for page_object in page_copy.page_objects:
- page_object.rotate(-angle, center, offset_x, offset_y)
-
- return (
- rotated_image,
- page_copy,
- )
diff --git a/utils/llm/__init__.py b/utils/llm/__init__.py
deleted file mode 100644
index 7220c77..0000000
--- a/utils/llm/__init__.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""Top-level public API for LLM utilities.
-
-This package exposes a clean, stable surface for:
-
-- Chat/completions: run_llm_simple_async
-- Message preparation and image encoding: prepare_messages, encode_image
-- Output parsing helpers: extract_tag_from_llm_output, string_to_list_of_floats, string_to_list_of_ints
-- Cost tracking: log_total_llm_cost, get_llm_total_cost
-
-Import from this module to avoid relying on internal structure:
-
- from churro.utils.llm import run_llm_simple_async
-
-"""
-
-from .core import LLMInferenceError, run_llm_async
-from .cost import get_llm_total_cost, log_total_llm_cost
-from .messages import encode_image, prepare_messages
-from .shutdown import shutdown_llm_clients
-from .types import ImageDetail, MessageContent, Messages, ModelInfo
-from .utils import (
- extract_tag_from_llm_output,
- string_to_list_of_floats,
- string_to_list_of_ints,
-)
-
-
-__all__ = [
- # core
- "run_llm_async",
- "LLMInferenceError",
- "shutdown_llm_clients",
- # messages
- "prepare_messages",
- "encode_image",
- # helpers
- "extract_tag_from_llm_output",
- "string_to_list_of_floats",
- "string_to_list_of_ints",
- # cost
- "log_total_llm_cost",
- "get_llm_total_cost",
- # types
- "MessageContent",
- "Messages",
- "ImageDetail",
- "ModelInfo",
-]
diff --git a/utils/llm/config.py b/utils/llm/config.py
deleted file mode 100644
index c965bcc..0000000
--- a/utils/llm/config.py
+++ /dev/null
@@ -1,104 +0,0 @@
-"""Configuration and environment setup for LLM utilities.
-
-Design goals:
- - Minimize side-effects at import time (no unconditional mutation of os.environ).
- - Centralize environment variable access and validation.
- - Provide a lazy initialization entrypoint so tests can override env before setup.
-
-Public API:
- - DEFAULT_TIMEOUT: int constant.
- - get_settings(): returns current snapshot of relevant settings.
- - ensure_initialized(): idempotent lazy initialization of litellm & caching.
-
-Environment variables (all optional unless a specific provider is used):
- - AZURE_API_BASE
- - AZURE_API_VERSION
- - AZURE_OPENAI_API_KEY
- - LOCAL_VLLM_PORT (for locally hosted vLLM servers)
- - VERTEX_AI_LOCATION
-"""
-
-from dataclasses import dataclass
-from functools import lru_cache
-
-import litellm
-from litellm.caching.caching import enable_cache
-
-from churro.config.settings import get_settings as get_churro_settings
-from churro.utils.log_utils import logger
-
-
-DEFAULT_TIMEOUT: int = 60 * 10 # 10 minutes
-
-_initialized: bool = False
-
-
-@dataclass(frozen=True)
-class LLMSettings:
- """Snapshot of environment-driven runtime settings."""
-
- azure_api_base: str | None
- azure_api_version: str | None
- azure_openai_api_key: str | None
- local_vllm_port: int | None
- vertex_ai_location: str
-
- @property
- def local_base_url(self) -> str | None:
- """Return the base URL for local vLLM server, or None if not configured."""
- if self.local_vllm_port:
- return f"http://localhost:{self.local_vllm_port}/v1"
- return None
-
-
-def ensure_initialized() -> None:
- """Idempotently initialize litellm configuration & caching.
-
- Safe to call multiple times; subsequent calls are no-ops.
- """
- global _initialized
- if _initialized:
- return
- enable_cache(type="disk") # type: ignore[arg-type]
- litellm.suppress_debug_info = True
- litellm.drop_params = True
- _initialized = True
-
-
-@lru_cache(maxsize=1)
-def get_settings() -> LLMSettings:
- """Return cached settings snapshot derived from environment variables."""
- base_settings = get_churro_settings()
- azure = base_settings.azure_openai
-
- azure_api_version = azure.api_version
- if not azure_api_version:
- # Do not force-set; just warn for visibility.
- logger.warning(
- "AZURE_API_VERSION not set; Azure model calls may fail if provider requires explicit version."
- )
- settings = LLMSettings(
- azure_api_base=azure.api_base,
- azure_api_version=azure_api_version,
- azure_openai_api_key=azure.api_key,
- local_vllm_port=base_settings.local.vllm_port,
- vertex_ai_location=base_settings.vertex_ai.location,
- )
-
- if settings.azure_api_base is None:
- logger.debug("AZURE_API_BASE is not set; skipping Azure-specific validation until used.")
- if settings.local_vllm_port is None:
- logger.debug(
- "LOCAL_VLLM_PORT is not set; local vLLM models will be unavailable until configured."
- )
- return settings
-
-
-# Gemini safety settings for Vertex requests
-GEMINI_SAFETY_SETTINGS: list[dict[str, str]] = [
- {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
- {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
- {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
- {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
- {"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"},
-]
diff --git a/utils/llm/core.py b/utils/llm/core.py
deleted file mode 100644
index f249ce5..0000000
--- a/utils/llm/core.py
+++ /dev/null
@@ -1,142 +0,0 @@
-"""Core chat/completion API for LLMs with provider fallback and caching."""
-
-from __future__ import annotations
-
-from typing import Any
-
-import litellm
-from litellm import acompletion
-from PIL import Image
-from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
-
-from churro.utils.log_utils import logger
-
-from .config import DEFAULT_TIMEOUT, ensure_initialized
-from .cost import cost_tracker
-from .messages import prepare_messages
-from .models import MODEL_MAP
-from .types import ImageDetail, Messages, ModelInfo
-
-
-class LLMInferenceError(RuntimeError):
- """Raised when all provider candidates fail or return unusable output."""
-
-
-def _get_model_candidates(model_key: str) -> list[ModelInfo]:
- """Return ordered list of provider candidates for a logical model key."""
- candidates = MODEL_MAP.get(model_key)
- if candidates is None:
- raise ValueError(f"Unknown model key: {model_key}")
- return candidates
-
-
-@retry(
- retry=retry_if_exception_type(
- (
- litellm.exceptions.APIError,
- litellm.exceptions.InternalServerError,
- litellm.exceptions.RateLimitError,
- )
- ),
- stop=stop_after_attempt(1),
- wait=wait_fixed(10),
-)
-async def _run_litellm(
- messages: Messages,
- model: str,
- output_json: bool = False,
- pydantic_class: type | None = None, # For JSON schema validation
- timeout: int = DEFAULT_TIMEOUT,
-) -> str:
- """Run an LLM inference asynchronously."""
- # Lazy init of underlying litellm/caching setup
- ensure_initialized()
- candidates = _get_model_candidates(model)
-
- # Try each candidate in order until one yields a non-empty answer
- last_error: Exception | None = None
- empty_response_seen = False
- for candidate in candidates:
- provider_model = candidate["provider_model"]
- # Build params per-candidate
- additional_params: dict[str, Any] = {}
- if candidate.get("static_params"):
- additional_params.update(candidate["static_params"]) # type: ignore[index]
- if output_json:
- additional_params["response_format"] = {"type": "json_object"}
- if pydantic_class:
- additional_params["response_format"] = pydantic_class
- if provider_model.startswith("vllm/"):
- # replace the model prefix for vllm, so that LiteLLM treats it as the OpenAI-compatible server that it is
- provider_model = provider_model.replace("vllm/", "openai/")
- num_retries = 1
- else:
- num_retries = 3
-
- try:
- response = await acompletion(
- model=provider_model,
- messages=messages,
- num_retries=num_retries,
- timeout=timeout,
- **additional_params,
- )
- answer = response.choices[0].message.content # type: ignore
- if (
- hasattr(response, "_hidden_params")
- and "response_cost" in response._hidden_params
- and response._hidden_params["response_cost"]
- ):
- cost_tracker.add_cost(response._hidden_params["response_cost"])
-
- if answer:
- return answer
- logger.error(
- f"LLM '{model}' via '{provider_model}' returned empty answer with finish_reason '{response.choices[0].finish_reason}'" # type: ignore
- )
- empty_response_seen = True
- except Exception as e: # Continue to next candidate on failure
- last_error = e
- logger.warning(
- f"Provider '{provider_model}' failed for model key '{model}': {e}. Trying next candidate if available."
- )
-
- # All candidates failed or returned empty
- if last_error:
- message = (
- f"All provider candidates failed for model key '{model}'. Last error: {last_error}"
- )
- logger.error(message)
- raise LLMInferenceError(message) from last_error
-
- message = f"All provider candidates returned empty output for model key '{model}'."
- if empty_response_seen:
- logger.error(message)
- raise LLMInferenceError(message)
-
-
-async def run_llm_async(
- model: str,
- system_prompt_text: str | None,
- user_message_text: str | None,
- user_message_image: Image.Image | list[Image.Image] | None = None,
- image_detail: ImageDetail | None = None,
- output_json: bool = False,
- pydantic_class: type | None = None,
- timeout: int = DEFAULT_TIMEOUT,
-) -> str:
- """Convenience wrapper around run_llm_async."""
- messages = prepare_messages(
- system_prompt_text,
- user_message_text,
- user_message_image,
- image_detail,
- )
-
- return await _run_litellm(
- messages,
- model,
- output_json=output_json,
- pydantic_class=pydantic_class,
- timeout=timeout,
- )
diff --git a/utils/llm/cost.py b/utils/llm/cost.py
deleted file mode 100644
index 726c93d..0000000
--- a/utils/llm/cost.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""LLM cost tracking utilities."""
-
-from churro.utils.log_utils import logger
-
-
-class LLMCostTracker:
- """Track total cost across all LLM calls."""
-
- def __init__(self) -> None:
- self._total_cost = 0.0
-
- def add_cost(self, cost: float) -> None:
- """Add cost to the total.
-
- Args:
- cost: Cost value in USD to add to the running total.
- """
- self._total_cost += cost
-
- def get_total_cost(self) -> float:
- """Get the total cost accumulated so far."""
- return self._total_cost
-
- def log_total_cost(self) -> None:
- """Log the total cost."""
- logger.info(f"Total LLM Cost: ${self._total_cost:.2f}")
-
-
-# Global cost tracker instance
-cost_tracker = LLMCostTracker()
-
-
-def log_total_llm_cost() -> None:
- """Log the total LLM cost."""
- cost_tracker.log_total_cost()
-
-
-def get_llm_total_cost() -> float:
- """Get the total LLM cost."""
- return cost_tracker.get_total_cost()
diff --git a/utils/llm/messages.py b/utils/llm/messages.py
deleted file mode 100644
index aa4d009..0000000
--- a/utils/llm/messages.py
+++ /dev/null
@@ -1,141 +0,0 @@
-"""Message preparation and image encoding helpers for LLM calls."""
-
-import base64
-from io import BytesIO
-import textwrap
-from typing import Any
-import weakref
-
-from PIL import Image
-
-from churro.utils.image.transform import resize_image_to_fit
-from churro.utils.log_utils import logger
-
-from .types import ImageDetail, MessageContent, Messages
-
-
-# Maximum dimensions for any image sent to the LLM (fits inside 2500 x 2500)
-_MAX_IMAGE_DIM: int = 2500
-
-# Encoding cache keyed by id(image) because PIL Image objects are unhashable.
-# Value is a tuple of (weakref to image, per-format map). We verify the weakref
-# still points to the same object before trusting the cached encodings. If the
-# object is gone or the id has been reused for a new image, we rebuild.
-_ENCODE_CACHE: dict[int, tuple["weakref.ReferenceType[Image.Image]", dict[str, str]]] = {}
-
-
-def encode_image(image: Image.Image, format: str = "JPEG") -> str:
- """Encode PIL Image to base64 string with downscale guard & weakref cache.
-
- Steps:
- 1. Downscale image in-place clone if it exceeds 2500x2500 (aspect preserved).
- 2. Normalize for JPEG (RGB/L only).
- 3. Consult weakref cache for previously encoded (image, format) pair.
- 4. Encode with stable parameters and store in cache.
- """
- fmt = (format or "JPEG").upper()
-
- key = id(image)
- cache_entry = _ENCODE_CACHE.get(key)
- cache_bucket: dict[str, str]
- if cache_entry is not None:
- img_ref, cache_bucket = cache_entry
- existing = img_ref()
- if existing is image:
- cached = cache_bucket.get(fmt)
- if cached is not None:
- logger.debug(f"Image served from encode cache ({fmt})")
- return cached
- else:
- # Stale entry (id reused or image collected); discard
- _ENCODE_CACHE.pop(key, None)
- cache_bucket = {}
- else:
- cache_bucket = {}
-
- img = image
- # Downscale guard (only if larger than box)
- if img.width > _MAX_IMAGE_DIM or img.height > _MAX_IMAGE_DIM:
- orig_w, orig_h = img.width, img.height
- img = resize_image_to_fit(img, _MAX_IMAGE_DIM, _MAX_IMAGE_DIM)
- logger.debug(
- f"Downscaled image {orig_w}x{orig_h} -> {img.width}x{img.height} for format {fmt}",
- )
-
- if fmt == "JPEG" and img.mode not in ("RGB", "L"):
- img = img.convert("RGB")
-
- buffer = BytesIO()
- save_kwargs: dict[str, Any] = {}
- if fmt == "JPEG":
- save_kwargs = {"quality": 95, "optimize": True}
- img.save(buffer, format=fmt, **save_kwargs)
- b64_str = base64.b64encode(buffer.getvalue()).decode("utf-8")
- cache_bucket[fmt] = b64_str
- # Store / update cache entry
- _ENCODE_CACHE[key] = (weakref.ref(image), cache_bucket)
- return b64_str
-
-
-def prepare_messages(
- system_prompt_text: str | None,
- user_message_text: str | None,
- user_message_image: Image.Image | None | list[Image.Image],
- image_detail: ImageDetail | None = None,
-) -> Messages:
- """Prepare messages for LLM inference with optional images."""
- user_message_content: list[MessageContent] = []
-
- if user_message_image:
- if not isinstance(user_message_image, list):
- user_message_image = [user_message_image]
-
- for umi in user_message_image:
- if umi.height <= 0 or umi.width <= 0:
- logger.warning(f"Invalid image dimensions: {umi.width}x{umi.height}")
- continue
-
- # Detect image format, default to PNG if unknown
- image_format = umi.format or "PNG"
- if image_format not in ["PNG", "JPEG", "WEBP"]:
- logger.warning(f"Unsupported image format: {image_format}, defaulting to PNG")
- image_format = "PNG"
-
- mime_type = f"image/{image_format.lower()}"
- try:
- encoded_image = encode_image(umi, image_format)
- image_url_payload: dict[str, Any] = {
- "url": f"data:{mime_type};base64,{encoded_image}",
- }
- if image_detail is not None:
- image_url_payload["detail"] = image_detail
- user_message_content.append(
- {
- "type": "image_url",
- "image_url": image_url_payload,
- }
- )
- except Exception as e:
- logger.error(f"Failed to encode image: {e}")
- continue
-
- if user_message_text:
- user_message_content.append({"type": "text", "text": textwrap.dedent(user_message_text)})
-
- messages: Messages = []
-
- if system_prompt_text:
- system_message: dict[str, Any] = {
- "role": "system",
- "content": [{"type": "text", "text": textwrap.dedent(system_prompt_text)}],
- }
- messages.append(system_message)
-
- messages.append(
- {
- "role": "user",
- "content": user_message_content,
- }
- )
-
- return messages
diff --git a/utils/llm/models.py b/utils/llm/models.py
deleted file mode 100644
index ddda7eb..0000000
--- a/utils/llm/models.py
+++ /dev/null
@@ -1,582 +0,0 @@
-"""Model registry mapping logical model keys to provider candidates."""
-
-from .config import GEMINI_SAFETY_SETTINGS, LLMSettings, get_settings
-from .types import ModelInfo
-
-
-COMPLETION_TOKENS_FOR_REASONING_MODELS = 40_000
-COMPLETION_TOKENS_FOR_STANDARD_MODELS = 20_000
-CHURRO_MODEL_ID: str = "stanford-oval/churro-3B"
-
-
-def _build_model_map(settings: LLMSettings) -> dict[str, list[ModelInfo]]:
- """Construct model registry for a given settings snapshot."""
- model_map: dict[str, list[ModelInfo]] = {
- # Azure/OpenAI GPT family (standard mode)
- "gpt-5-low": [
- {
- "provider_model": "azure/gpt-5",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "low"},
- },
- {
- "provider_model": "gpt-5",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "low"},
- },
- ],
- "gpt-5-medium": [
- {
- "provider_model": "azure/gpt-5",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "medium"},
- },
- {
- "provider_model": "gpt-5",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "medium"},
- },
- ],
- "gpt-5-mini-low": [
- {
- "provider_model": "azure/gpt-5-mini",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "low"},
- },
- {
- "provider_model": "gpt-5-mini",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "low"},
- },
- ],
- "gpt-5-mini-medium": [
- {
- "provider_model": "azure/gpt-5-mini",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "medium"},
- },
- {
- "provider_model": "gpt-5-mini",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "medium"},
- },
- ],
- "gpt-5-nano-low": [
- {
- "provider_model": "azure/gpt-5-nano",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "low"},
- },
- {
- "provider_model": "gpt-5-nano",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "low"},
- },
- ],
- "gpt-5-nano-medium": [
- {
- "provider_model": "azure/gpt-5-nano",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "medium"},
- },
- {
- "provider_model": "gpt-5-nano",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "medium"},
- },
- ],
- "gpt-4o": [
- {"provider_model": "azure/gpt-4o", "max_completion_tokens": None},
- {"provider_model": "gpt-4o", "max_completion_tokens": None},
- ],
- "gpt-4o-mini": [
- {
- "provider_model": "azure/gpt-4o-mini",
- "max_completion_tokens": None,
- },
- {
- "provider_model": "gpt-4o-mini",
- "max_completion_tokens": None,
- },
- ],
- "gpt-4.1": [
- {"provider_model": "azure/gpt-4.1", "max_completion_tokens": None},
- {"provider_model": "gpt-4.1", "max_completion_tokens": None},
- ],
- "gpt-4.1-mini": [
- {
- "provider_model": "azure/gpt-4.1-mini",
- "max_completion_tokens": None,
- },
- {
- "provider_model": "gpt-4.1-mini",
- "max_completion_tokens": None,
- },
- ],
- "gpt-4.1-nano": [
- {
- "provider_model": "azure/gpt-4.1-nano",
- "max_completion_tokens": None,
- },
- {
- "provider_model": "gpt-4.1-nano",
- "max_completion_tokens": None,
- },
- ],
- # Reasoning models: use suffixed keys to denote effort level
- "o1-low": [
- {
- "provider_model": "azure/o1",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "low"},
- }
- ],
- "o1-medium": [
- {
- "provider_model": "azure/o1",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "medium"},
- }
- ],
- "o3-low": [
- {
- "provider_model": "o3",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "low"},
- }
- ],
- "o3-medium": [
- {
- "provider_model": "o3",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "medium"},
- }
- ],
- "o4-mini-low": [
- {
- "provider_model": "azure/o4-mini",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "low"},
- },
- {
- "provider_model": "o4-mini",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "low"},
- },
- ],
- "o4-mini-medium": [
- {
- "provider_model": "azure/o4-mini",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "medium"},
- },
- {
- "provider_model": "o4-mini",
- "max_completion_tokens": None,
- "static_params": {"reasoning_effort": "medium"},
- },
- ],
- "chatgpt-4o": [
- {
- "provider_model": "chatgpt-4o-latest",
- "max_completion_tokens": None,
- },
- ],
- # Gemini on Vertex AI
- "gemini-2.5-flash-noreasoning": [
- {
- "provider_model": "vertex_ai/gemini-2.5-flash",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {
- "safety_settings": GEMINI_SAFETY_SETTINGS,
- "vertex_location": settings.vertex_ai_location,
- "reasoning_effort": "disable",
- },
- }
- ],
- "gemini-2.5-flash-preview-medium": [
- {
- "provider_model": "vertex_ai/gemini-2.5-flash-preview-09-2025",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_REASONING_MODELS,
- "static_params": {
- "safety_settings": GEMINI_SAFETY_SETTINGS,
- "vertex_location": settings.vertex_ai_location,
- "reasoning_effort": "medium",
- },
- }
- ],
- "gemini-2.5-flash-low": [
- {
- "provider_model": "vertex_ai/gemini-2.5-flash",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_REASONING_MODELS,
- "static_params": {
- "safety_settings": GEMINI_SAFETY_SETTINGS,
- "vertex_location": settings.vertex_ai_location,
- "reasoning_effort": "low",
- },
- }
- ],
- "gemini-2.5-pro-low": [
- {
- "provider_model": "vertex_ai/gemini-2.5-pro",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_REASONING_MODELS,
- "static_params": {
- "safety_settings": GEMINI_SAFETY_SETTINGS,
- "vertex_location": settings.vertex_ai_location,
- "reasoning_effort": "low",
- },
- }
- ],
- "gemini-2.5-flash-medium": [
- {
- "provider_model": "vertex_ai/gemini-2.5-flash",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_REASONING_MODELS,
- "static_params": {
- "safety_settings": GEMINI_SAFETY_SETTINGS,
- "vertex_location": settings.vertex_ai_location,
- "reasoning_effort": "medium",
- },
- }
- ],
- "gemini-2.5-pro-medium": [
- {
- "provider_model": "vertex_ai/gemini-2.5-pro",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_REASONING_MODELS,
- "static_params": {
- "safety_settings": GEMINI_SAFETY_SETTINGS,
- "vertex_location": settings.vertex_ai_location,
- "reasoning_effort": "medium",
- },
- }
- ],
- "gemini-2.5-flash-high": [
- {
- "provider_model": "vertex_ai/gemini-2.5-flash",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_REASONING_MODELS,
- "static_params": {
- "safety_settings": GEMINI_SAFETY_SETTINGS,
- "vertex_location": settings.vertex_ai_location,
- "reasoning_effort": "high",
- },
- }
- ],
- "gemini-2.5-pro-high": [
- {
- "provider_model": "vertex_ai/gemini-2.5-pro",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_REASONING_MODELS,
- "static_params": {
- "safety_settings": GEMINI_SAFETY_SETTINGS,
- "vertex_location": settings.vertex_ai_location,
- "thinking": {"type": "enabled", "budget_tokens": 32768},
- },
- }
- ],
- # Claude models on Vertex
- "sonnet-3.7": [
- {
- "provider_model": "vertex_ai/claude-3-7-sonnet@20250219",
- "max_completion_tokens": None,
- "static_params": {
- "thinking": {"type": "disabled"},
- "vertex_location": settings.vertex_ai_location,
- },
- },
- ],
- "sonnet-3.7-low": [
- {
- "provider_model": "vertex_ai/claude-3-7-sonnet@20250219",
- "max_completion_tokens": None,
- "static_params": {
- "reasoning_effort": "low",
- "vertex_location": settings.vertex_ai_location,
- },
- },
- ],
- "sonnet-3.7-medium": [
- {
- "provider_model": "vertex_ai/claude-3-7-sonnet@20250219",
- "max_completion_tokens": None,
- "static_params": {
- "reasoning_effort": "medium",
- "vertex_location": settings.vertex_ai_location,
- },
- },
- ],
- "sonnet-4-medium": [
- {
- "provider_model": "vertex_ai/claude-sonnet-4@20250514",
- "max_completion_tokens": None,
- "static_params": {
- "reasoning_effort": "medium",
- "vertex_location": settings.vertex_ai_location,
- },
- },
- ],
- "opus-4.1-medium": [
- {
- "provider_model": "vertex_ai/claude-opus-4-1@20250805",
- "max_completion_tokens": None,
- "static_params": {
- "reasoning_effort": "medium",
- "vertex_location": settings.vertex_ai_location,
- },
- },
- ],
- # Hosted vLLM models
- "qwen25-3b": [
- {
- "provider_model": "vllm/qwen25-3b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "Qwen/Qwen2.5-VL-3B-Instruct",
- }
- ],
- "qwen25-7b": [
- {
- "provider_model": "vllm/qwen25-7b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "Qwen/Qwen2.5-VL-7B-Instruct",
- }
- ],
- "qwen25-32b": [
- {
- "provider_model": "vllm/qwen25-32b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "Qwen/Qwen2.5-VL-32B-Instruct",
- }
- ],
- "qwen25-72b": [
- {
- "provider_model": "vllm/qwen25-72b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "Qwen/Qwen2.5-VL-72B-Instruct",
- }
- ],
- "aria": [
- {
- "provider_model": "vllm/aria",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "rhymes-ai/Aria",
- }
- ],
- "phi4-multimodal": [
- {
- "provider_model": "vllm/phi4-multimodal",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "microsoft/Phi-4-multimodal-instruct",
- }
- ],
- "aya-32b": [
- {
- "provider_model": "vllm/aya-32b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "CohereLabs/aya-vision-32b",
- }
- ],
- "mistral-small-3.2-24b": [
- {
- "provider_model": "vllm/mistral-small-3.2-24b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "unsloth/Mistral-Small-3.2-24B-Instruct-2506", # The official Mistral model does not load due to a missing config file
- }
- ],
- "gemma-3-27b": [
- {
- "provider_model": "vllm/gemma-3-27b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "google/gemma-3-27b-it",
- }
- ],
- "mimo-vl-7b-rl": [
- {
- "provider_model": "vllm/mimo-vl-7b-rl",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "XiaomiMiMo/MiMo-VL-7B-RL-2508",
- }
- ],
- "nanonets-ocr-s": [
- {
- "provider_model": "vllm/nanonets-ocr-s",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "nanonets/Nanonets-OCR-s",
- }
- ],
- "skywork-r1v3-38b": [
- {
- "provider_model": "vllm/skywork-r1v3-38b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "Skywork/Skywork-R1V3-38B",
- }
- ],
- "numarkdown-8b": [
- {
- "provider_model": "vllm/numarkdown-8b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_REASONING_MODELS,
- "static_params": {
- "api_base": settings.local_base_url,
- },
- "hf_repo": "numind/NuMarkdown-8B-Thinking",
- }
- ],
- "r-4b": [
- {
- "provider_model": "vllm/r-4b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_REASONING_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "YannQi/R-4B",
- }
- ],
- "nemotron-nano-vl-8b": [
- {
- "provider_model": "vllm/nemotron-nano-vl-8b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "nvidia/Llama-3.1-Nemotron-Nano-VL-8B-V1",
- }
- ],
- "internvl3.5-30b": [
- {
- "provider_model": "vllm/internvl3.5-30b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_REASONING_MODELS,
- "static_params": {
- "api_base": settings.local_base_url,
- },
- "hf_repo": "OpenGVLab/InternVL3_5-30B-A3B",
- }
- ],
- "rolmocr": [
- {
- "provider_model": "vllm/rolmocr",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "reducto/RolmOCR",
- }
- ],
- "olmocr": [
- {
- "provider_model": "vllm/olmocr",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "allenai/olmOCR-7B-0825",
- }
- ],
- "minicpm-v-4.5": [
- {
- "provider_model": "vllm/minicpm-v-4.5",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "openbmb/MiniCPM-V-4_5",
- }
- ],
- "qwen3-vl-30b": [
- {
- "provider_model": "vllm/qwen3-vl-30b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "Qwen/Qwen3-VL-30B-A3B-Instruct",
- }
- ],
- "qwen3-vl-4b": [
- {
- "provider_model": "vllm/qwen3-vl-4b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "Qwen/Qwen3-VL-4B-Instruct",
- }
- ],
- "qwen3-vl-8b": [
- {
- "provider_model": "vllm/qwen3-vl-8b",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {"api_base": settings.local_base_url},
- "hf_repo": "Qwen/Qwen3-VL-8B-Instruct",
- }
- ],
- "churro": [
- {
- "provider_model": "vllm/churro",
- "max_completion_tokens": COMPLETION_TOKENS_FOR_STANDARD_MODELS,
- "static_params": {
- "api_base": settings.local_base_url,
- "temperature": 0.6,
- },
- "hf_repo": CHURRO_MODEL_ID,
- }
- ],
- }
- return model_map
-
-
-MODEL_MAP: dict[str, list[ModelInfo]] = _build_model_map(get_settings())
-
-
-def reload_model_map(settings: LLMSettings | None = None) -> dict[str, list[ModelInfo]]:
- """Rebuild the global model map using the provided settings snapshot."""
- global MODEL_MAP
- resolved = settings or get_settings()
- MODEL_MAP = _build_model_map(resolved)
- _validate_model_registry()
- return MODEL_MAP
-
-
-def _validate_model_registry() -> None:
- """Validate MODEL_MAP structure at import time.
-
- Checks:
- - Keys are non-empty strings.
- - Each value is a non-empty list.
- - Required fields in each candidate: provider_model (str), max_completion_tokens (int|None).
- - static_params, if present, is a dict.
- - hf_repo, if present, is a non-empty string.
- - vLLM providers map to an existing logical key matching their suffix.
- Raises ValueError on first encountered issue to fail fast during startup/tests.
- """
- for logical_key, candidates in MODEL_MAP.items():
- if not isinstance(logical_key, str) or not logical_key.strip():
- raise ValueError(f"Model registry key must be non-empty string: {logical_key!r}")
- if not isinstance(candidates, list) or not candidates:
- raise ValueError(f"Model registry entry for '{logical_key}' must be a non-empty list")
- for idx, cand in enumerate(candidates):
- if "provider_model" not in cand or not isinstance(cand["provider_model"], str):
- raise ValueError(
- f"Model '{logical_key}' candidate {idx} missing valid 'provider_model'"
- )
- provider_model = cand["provider_model"]
- if provider_model.startswith("vllm/"):
- vllm_key = provider_model.split("/", 1)[1]
- if vllm_key != logical_key:
- raise ValueError(
- f"Model '{logical_key}' candidate {idx} references '{provider_model}' "
- f"but '{vllm_key}' is not a registered logical key"
- )
- if "max_completion_tokens" not in cand or not (
- isinstance(cand["max_completion_tokens"], int | type(None))
- ):
- raise ValueError(
- f"Model '{logical_key}' candidate {idx} has invalid 'max_completion_tokens'"
- )
- if "static_params" in cand and not isinstance(cand["static_params"], dict):
- raise ValueError(
- f"Model '{logical_key}' candidate {idx} 'static_params' must be a dict if present"
- )
- if "hf_repo" in cand and not (
- isinstance(cand["hf_repo"], str) and cand["hf_repo"].strip()
- ):
- raise ValueError(
- f"Model '{logical_key}' candidate {idx} 'hf_repo' must be non-empty string if present"
- )
-
-
-# Execute validation immediately so misconfigurations surface early.
-_validate_model_registry()
diff --git a/utils/llm/shutdown.py b/utils/llm/shutdown.py
deleted file mode 100644
index c4c5f7b..0000000
--- a/utils/llm/shutdown.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""Shutdown helpers for LLM utilities.
-
-Provides an async function to gracefully close any underlying HTTP client sessions
-opened by litellm (which internally uses aiohttp for some providers). This helps
-suppress warnings about unclosed ClientSession objects at interpreter shutdown
-when our pipelines perform many concurrent requests and exit quickly.
-
-If litellm changes its internal structure, failure to locate the session is
-silently ignored.
-"""
-
-from __future__ import annotations
-
-import gc
-from typing import Any
-
-import litellm
-
-from churro.utils.log_utils import logger
-
-
-async def _gather_candidate_sessions() -> set[Any]:
- """Return a set of aiohttp.ClientSession-like objects discovered heuristically."""
- sessions: set[Any] = set()
- try:
- import aiohttp
- except Exception:
- return sessions
-
- # 1. Probe known litellm attributes
- for _name, value in list(litellm.__dict__.items()):
- if isinstance(value, aiohttp.ClientSession): # type: ignore[attr-defined]
- sessions.add(value)
-
- # 2. Probe common attribute names on litellm module itself
- for attr in ["_client_session", "client_session", "session"]:
- value = getattr(litellm, attr, None)
- if isinstance(value, aiohttp.ClientSession): # type: ignore[attr-defined]
- sessions.add(value)
-
- # 3. OpenAI aiosession accessor (handles old/new patterns)
- try: # pragma: no cover - optional dependency
- import openai # type: ignore
-
- aiosession = getattr(openai, "aiosession", None)
- if aiosession and hasattr(aiosession, "get"):
- get_fn = aiosession.get
- maybe = get_fn() # returns session or awaitable depending on version
- if hasattr(maybe, "__await__"):
- maybe = await maybe # type: ignore[assignment]
- if isinstance(maybe, aiohttp.ClientSession): # type: ignore[attr-defined]
- sessions.add(maybe)
- except Exception:
- pass
-
- # 4. GC scan as last resort (may find additional leaked sessions)
- try:
- import aiohttp # type: ignore # re-import inside scope for mypy
-
- for obj in gc.get_objects():
- if isinstance(obj, aiohttp.ClientSession) and not obj.closed: # type: ignore[attr-defined]
- sessions.add(obj)
- except Exception: # pragma: no cover
- pass
-
- return sessions
-
-
-async def shutdown_llm_clients() -> None:
- """Attempt to gracefully close underlying aiohttp ClientSession objects.
-
- Strategy:
- * Probe litellm module attributes for sessions
- * Probe openai.aiosession (old + new patterns)
- * GC scan fallback
- Safe to call multiple times.
- """
- try:
- sessions = await _gather_candidate_sessions()
- closed = 0
- for sess in sessions:
- try:
- if getattr(sess, "closed", False):
- continue
- maybe = sess.close()
- if hasattr(maybe, "__await__"):
- await maybe
- closed += 1
- except Exception as e: # pragma: no cover
- logger.debug(f"Failed closing session {sess}: {e}")
- if closed:
- logger.debug(f"Closed {closed} aiohttp session(s) during shutdown")
- except Exception as e: # pragma: no cover - defensive
- logger.debug(f"LLM client shutdown encountered error: {e}")
diff --git a/utils/llm/types.py b/utils/llm/types.py
deleted file mode 100644
index 9e6a85e..0000000
--- a/utils/llm/types.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""Common type definitions for the llm package."""
-
-from typing import Any, Literal, NotRequired, TypedDict
-
-
-class ModelInfo(TypedDict):
- """Single provider candidate configuration for a logical model.
-
- Attributes:
- provider_model: Underlying provider/model identifier (used by litellm)
- max_completion_tokens: Suggested maximum output length for completions (None to omit)
- static_params: Provider-specific static params to always include
- hf_repo: Optional HF repo name used to launch a local vLLM container
- """
-
- provider_model: str
- max_completion_tokens: int | None
- static_params: NotRequired[dict[str, Any]]
- hf_repo: NotRequired[
- str
- ] # Optional: For locally hosted models, specify the backing Hugging Face repo to use
-
-
-MessageContent = dict[str, Any]
-Messages = list[dict[str, Any]]
-ImageDetail = Literal["high", "auto", "low"]
diff --git a/utils/llm/utils.py b/utils/llm/utils.py
deleted file mode 100644
index fa14443..0000000
--- a/utils/llm/utils.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""Lightweight helpers for parsing and conversions used with LLM outputs."""
-
-from churro.utils.log_utils import logger
-
-
-def extract_tag_from_llm_output(llm_output: str, tags: str | list[str]) -> str | list[str]:
- """Extract tagged content from churro.utils.llm output."""
- is_list = isinstance(tags, list)
- if not is_list:
- assert isinstance(tags, str)
- tags = [tags]
- all_extracted_tags: list[str] = []
- for tag in tags:
- extracted_tag = ""
- open_tag = f"<{tag}>"
- close_tag = f"{tag}>"
- start_idx = llm_output.find(open_tag)
- if start_idx == -1:
- all_extracted_tags.append("")
- continue
- content_start = start_idx + len(open_tag)
- end_idx = llm_output.find(close_tag, content_start)
- if end_idx == -1:
- all_extracted_tags.append("")
- continue
- extracted_tag = llm_output[content_start:end_idx].strip()
- all_extracted_tags.append(extracted_tag)
-
- if not is_list:
- return all_extracted_tags[0]
- return all_extracted_tags
-
-
-def string_to_list_of_floats(string: str) -> list[float]:
- """Parse a string representation of a float list.
-
- Example input: "[0.5, 0.6, 0.7, 45]"
- """
- try:
- string = string.strip("[]").strip()
- if not string:
- return []
- return [float(x.strip()) for x in string.split(",")]
- except Exception as e:
- logger.warning(f"Failed to parse float list from '{string}': {e}")
- return []
-
-
-def string_to_list_of_ints(string: str) -> list[int]:
- """Parse a string representation of an int list.
-
- Example input: "[1, 2, 3, 4]"
- """
- try:
- string = string.strip("[]").strip()
- if not string:
- return []
- return [int(x.strip()) for x in string.split(",")]
- except Exception as e:
- logger.warning(f"Failed to parse int list from '{string}': {e}")
- return []
diff --git a/utils/log_utils.py b/utils/log_utils.py
deleted file mode 100644
index 5c7947a..0000000
--- a/utils/log_utils.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""Logging utilities shared across the churro package."""
-
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Any
-
-from loguru import logger
-from rich.logging import RichHandler
-
-
-_CONFIGURED: bool = False
-
-DEFAULT_CONSOLE_LEVEL = "INFO"
-DEFAULT_FILE_LEVEL = "DEBUG"
-DEFAULT_FILE_PATH = "debug_logs.log"
-DEFAULT_FILE_ROTATION = "5 MB"
-DEFAULT_FILE_RETENTION = 2
-
-_RICH_HANDLER_KWARGS: dict[str, Any] = {
- "markup": True,
- "show_time": False,
-}
-
-
-def _configure_logging(*, force: bool = False) -> None:
- """Configure the shared logger once per process."""
- global _CONFIGURED
- if _CONFIGURED and not force:
- return
-
- logger.remove()
-
- logger.add(
- RichHandler(**_RICH_HANDLER_KWARGS), # type: ignore[arg-type]
- level=DEFAULT_CONSOLE_LEVEL,
- format="{message}",
- )
-
- if DEFAULT_FILE_PATH:
- resolved_file_path = Path(DEFAULT_FILE_PATH).expanduser().resolve()
- resolved_file_path.parent.mkdir(parents=True, exist_ok=True)
- logger.add(
- str(resolved_file_path),
- level=DEFAULT_FILE_LEVEL,
- format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {name} | {message}",
- rotation=DEFAULT_FILE_ROTATION,
- retention=DEFAULT_FILE_RETENTION,
- enqueue=True,
- )
-
- _CONFIGURED = True
-
-
-__all__ = ["logger"]
-
-# Configure logging on import so callers only need to import `logger`.
-_configure_logging()
diff --git a/utils/pdf/__init__.py b/utils/pdf/__init__.py
deleted file mode 100644
index 9132d24..0000000
--- a/utils/pdf/__init__.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""PDF processing utilities.
-
-This package provides both a simple script-style interface (see
-``pdfs_to_images.py``) and an asynchronous queued pipeline (``runner.py``)
-for converting PDF documents into one or more processed page images.
-
-Primary public entry point:
- ``run_pdf_pipeline`` – an asyncio based producer/consumer pipeline that:
- 1. Rasterizes PDF pages in a separate process pool.
- 2. Uses an LLM to decide if a scanned image contains one or two pages.
- 3. Optionally trims page content using detected layout.
- 4. Persists output PNG files while preserving logical ordering.
-
-Multiprocessing:
- Always uses the ``spawn`` start method to avoid potential fork-related deadlocks with threads or async runtimes.
-"""
-
-from .runner import run_pdf_pipeline
-
-
-__all__ = ["run_pdf_pipeline"]
diff --git a/utils/pdf/pdfs_to_images.py b/utils/pdf/pdfs_to_images.py
deleted file mode 100644
index dfadfd5..0000000
--- a/utils/pdf/pdfs_to_images.py
+++ /dev/null
@@ -1,112 +0,0 @@
-"""Minimal helper utilities for the queued PDF pipeline."""
-
-from __future__ import annotations
-
-import numpy as np
-from PIL import Image
-
-from churro.page.page_object import PageObject
-from churro.page.visualization import extract_polygon_region
-from churro.utils.log_utils import logger
-
-
-__all__ = [
- "find_brightest_line",
- "split_double_page",
-]
-
-
-def find_brightest_line(image: Image.Image, margin_ratio: float = 0.45) -> tuple[int, int]:
- """Locate a bright vertical (possibly slanted) separator line.
-
- A brute-force search scans candidate start/end x-coordinates (sampled every
- few pixels) and accumulates grayscale brightness along the implied line.
- The line with the highest summed intensity—subject to exclusion margins on
- the left/right edges—is returned.
-
- Args:
- image: Source PIL image (mode is converted internally to ``L``).
- margin_ratio: Fractional horizontal margin to ignore on both sides.
-
- Returns:
- (best_x0, best_x1) integer coordinates representing the top and bottom
- x positions of the detected line.
- """
- gray = image.convert("L")
- arr = np.asarray(gray, dtype=float)
- h, w = arr.shape
-
- margin_x_min = int(margin_ratio * w)
- margin_x_max = int((1 - margin_ratio) * w)
-
- best_x0 = 0
- best_x1 = 0
- max_sum = -1.0
-
- # Precompute all row indices for faster vector calculations
- y_indices = np.arange(h)
-
- step_size = 5
- for x0 in range(margin_x_min, margin_x_max, step_size):
- for x1 in range(margin_x_min, margin_x_max, step_size):
- slope = (x1 - x0) / h
- # Compute all x positions and round once using NumPy
- x_positions = x0 + slope * y_indices
- x_rounded = np.round(x_positions).astype(int)
-
- # Sum brightness in one vector operation
- total_brightness = float(np.sum(arr[y_indices, x_rounded]))
-
- if total_brightness > max_sum:
- max_sum = total_brightness
- best_x0 = x0
- best_x1 = x1
-
- return best_x0, best_x1
-
-
-def split_double_page(image: Image.Image) -> list[Image.Image]:
- """Split a detected double-page scan into left and right page images.
-
- Uses the brightest vertical separator line (see ``find_brightest_line``)
- plus a small pixel margin to build polygons that are cropped out of the
- original image.
- """
- # Split the image in half
- best_x0, best_x1 = find_brightest_line(image)
- logger.debug(f"best_x0: {best_x0}, best_x1: {best_x1}")
-
- # Add margin of error to polygon coordinates
- margin = 10 # pixels
-
- left_polygon = PageObject(
- object_id="split-left",
- coordinates=[
- 0,
- 0,
- best_x0 + margin,
- 0,
- best_x1 + margin,
- image.height,
- 0,
- image.height,
- ],
- )
- left_image = extract_polygon_region(image=image, page_object=left_polygon)
-
- right_polygon = PageObject(
- object_id="split-right",
- coordinates=[
- best_x0 - margin,
- 0,
- best_x1 - margin,
- image.height,
- image.width,
- image.height,
- image.width,
- 0,
- ],
- )
- right_image = extract_polygon_region(image=image, page_object=right_polygon)
-
- return [left_image, right_image]
diff --git a/utils/pdf/runner.py b/utils/pdf/runner.py
deleted file mode 100644
index ce15af0..0000000
--- a/utils/pdf/runner.py
+++ /dev/null
@@ -1,694 +0,0 @@
-"""Queued asynchronous PDF processing pipeline with staged concurrency."""
-
-from __future__ import annotations
-
-import asyncio
-from collections.abc import Iterable, Sequence
-from concurrent.futures import ProcessPoolExecutor
-from dataclasses import dataclass
-from io import BytesIO
-import os
-from typing import Protocol
-
-import fitz # PyMuPDF
-from PIL import Image
-
-from churro.systems.detect_layout import shutdown_layout_clients, tidy_image_via_layout_detection
-from churro.utils.concurrency import ProgressReporter
-from churro.utils.llm import extract_tag_from_llm_output, run_llm_async
-from churro.utils.llm.shutdown import shutdown_llm_clients
-from churro.utils.log_utils import logger
-from churro.utils.pdf.pdfs_to_images import split_double_page
-
-
-SUPPORTED_IMAGE_EXTENSIONS = {
- ".png",
- ".jpg",
- ".jpeg",
- ".tif",
- ".tiff",
- ".bmp",
- ".gif",
-}
-
-DEFAULT_RASTER_DPI = 300
-SINGLE_PAGE_ASPECT_RATIO_THRESHOLD = 1.0
-
-
-def _collect_image_paths(image_dir: str) -> list[str]:
- collected: list[str] = []
- for root, _, files in os.walk(image_dir):
- for file_name in files:
- ext = os.path.splitext(file_name)[1].lower()
- if ext in SUPPORTED_IMAGE_EXTENSIONS:
- collected.append(os.path.join(root, file_name))
- return sorted(collected)
-
-
-@dataclass(slots=True)
-class RasterTask:
- pdf_id: int
- pdf_path: str
- page_index: int
- png_bytes: bytes
-
-
-@dataclass(slots=True)
-class PageSplitGroup:
- pdf_id: int
- pdf_path: str
- page_index: int
- images: list[Image.Image]
-
-
-def _infer_native_dpi(page: fitz.Page) -> int | None:
- images = page.get_images(full=True)
- if not images:
- return None
-
- best_dpi: float | None = None
- best_area = 0.0
- page_number = getattr(page, "number", getattr(page, "index", -1))
-
- for image in images:
- xref = image[0]
- width = image[2]
- height = image[3]
- try:
- rects = page.get_image_rects(xref)
- except Exception as exc: # pragma: no cover - defensive against PyMuPDF edge cases
- logger.debug(
- "Failed to compute image rects for xref=%s on page %s: %s",
- xref,
- page_number,
- exc,
- )
- continue
-
- for rect in rects:
- area = rect.width * rect.height
- if area <= 0:
- continue
-
- width_inches = rect.width / 72.0
- height_inches = rect.height / 72.0
- if width_inches <= 0 or height_inches <= 0:
- continue
-
- x_dpi = width / width_inches
- y_dpi = height / height_inches
- candidate = max(x_dpi, y_dpi)
- if candidate <= 0:
- continue
-
- if area > best_area:
- best_area = area
- best_dpi = candidate
- elif best_area and abs(area - best_area) < 1e-6:
- if best_dpi is None or candidate > best_dpi:
- best_dpi = candidate
-
- if best_dpi is None:
- return None
-
- return max(int(round(best_dpi)), 1)
-
-
-def _raster_batch(
- pdf_path: str,
- page_numbers: list[int],
- dpi_override: int | None,
- fallback_dpi: int,
-) -> list[tuple[int, bytes]]:
- doc = fitz.open(pdf_path)
- out: list[tuple[int, bytes]] = []
- try:
- for page_number in page_numbers:
- try:
- page = doc[page_number]
- target_dpi = dpi_override
- native_dpi = None
- if target_dpi is None:
- native_dpi = _infer_native_dpi(page)
- target_dpi = native_dpi or fallback_dpi
- if native_dpi is None:
- logger.debug(
- "Falling back to %s DPI for %s page %s; no native DPI detected.",
- fallback_dpi,
- pdf_path,
- page_number,
- )
-
- zoom = max(target_dpi, 1) / 72.0
- pix = page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)) # type: ignore[attr-defined]
- out.append((page_number, pix.tobytes("png")))
- except Exception as exc: # pragma: no cover
- logger.error(f"Failed to rasterize page {page_number} of {pdf_path}: {exc}")
- return out
- finally:
- doc.close()
-
-
-class RasterizationStage:
- """CPU-bound PDF rasterization running in a process pool."""
-
- def __init__(
- self,
- *,
- dpi: int | None,
- batch_pages: int,
- max_workers: int,
- fallback_dpi: int = DEFAULT_RASTER_DPI,
- ) -> None:
- self._dpi_override = dpi
- self._batch_pages = batch_pages
- self._max_workers = max_workers
- self._fallback_dpi = fallback_dpi
-
- async def produce(
- self,
- pdf_paths: Sequence[str],
- queue: asyncio.Queue[RasterTask | None],
- *,
- start_pdf_id: int = 0,
- ) -> None:
- loop = asyncio.get_running_loop()
-
- from multiprocessing import get_context # type: ignore
-
- ctx = get_context("spawn")
- with ProcessPoolExecutor(max_workers=self._max_workers, mp_context=ctx) as pool:
- for offset, pdf_path in enumerate(pdf_paths):
- pdf_id = start_pdf_id + offset
- try:
- with fitz.open(pdf_path) as doc:
- page_numbers = list(range(doc.page_count))
- except Exception as exc:
- logger.error(f"Skipping {pdf_path}: cannot open ({exc})")
- continue
-
- batches = [
- page_numbers[i : i + self._batch_pages]
- for i in range(0, len(page_numbers), self._batch_pages)
- ]
- for batch in batches:
- while queue.full():
- await asyncio.sleep(0)
- fut = loop.run_in_executor(
- pool,
- _raster_batch,
- pdf_path,
- batch,
- self._dpi_override,
- self._fallback_dpi,
- )
- for page_index, png_bytes in await fut:
- await queue.put(
- RasterTask(
- pdf_id=pdf_id,
- pdf_path=pdf_path,
- page_index=page_index,
- png_bytes=png_bytes,
- )
- )
-
-
-class ImageIngestionStage:
- """Stage that normalizes supplied image files into raster tasks."""
-
- async def produce(
- self,
- image_paths: Sequence[str],
- queue: asyncio.Queue[RasterTask | None],
- *,
- start_pdf_id: int,
- ) -> None:
- for offset, image_path in enumerate(image_paths):
- while queue.full():
- await asyncio.sleep(0)
- try:
- with Image.open(image_path) as img:
- buffer = BytesIO()
- img.convert("RGB").save(buffer, format="PNG")
- except Exception as exc:
- logger.error(f"Skipping {image_path}: cannot open/convert image ({exc})")
- continue
- await queue.put(
- RasterTask(
- pdf_id=start_pdf_id + offset,
- pdf_path=image_path,
- page_index=0,
- png_bytes=buffer.getvalue(),
- )
- )
-
-
-class PageSplitterFactory(Protocol):
- def __call__(self, engine: str) -> PageSplitter: ...
-
-
-class PageTrimmerFactory(Protocol):
- def __call__(self, enable_trim: bool) -> PageTrimmer: ...
-
-
-class PageSplitter(Protocol):
- async def split(self, image: Image.Image) -> list[Image.Image]: ...
-
-
-class PageTrimmer(Protocol):
- async def trim(self, image: Image.Image) -> Image.Image: ...
-
-
-class LLMPageSplitter(PageSplitter):
- """Default splitter that consults an LLM to decide whether to split pages."""
-
- def __init__(self, *, engine: str) -> None:
- self._engine = engine
-
- async def split(self, image: Image.Image) -> list[Image.Image]:
- width, height = image.size
- if height <= 0:
- logger.debug(
- f"Skipping LLM page split because image height is non-positive (width={width}, height={height})."
- )
- return [image]
-
- aspect_ratio = width / height
- if aspect_ratio <= SINGLE_PAGE_ASPECT_RATIO_THRESHOLD:
- logger.debug(
- f"Skipping LLM page split for narrow image (width={width}, height={height}, aspect_ratio={aspect_ratio:.3f})."
- )
- return [image]
-
- llm_output = await run_llm_async(
- system_prompt_text=None,
- user_message_text=PAGE_SPLIT_PROMPT,
- user_message_image=image,
- image_detail="low",
- model=self._engine,
- )
- number_of_pages = _parse_number_of_pages(llm_output)
- return split_double_page(image) if number_of_pages == 2 else [image]
-
-
-class LayoutTrimmer(PageTrimmer):
- async def trim(self, image: Image.Image) -> Image.Image:
- return await tidy_image_via_layout_detection(image)
-
-
-class IdentityTrimmer(PageTrimmer):
- async def trim(self, image: Image.Image) -> Image.Image:
- return image
-
-
-def default_splitter_factory(engine: str) -> PageSplitter:
- return LLMPageSplitter(engine=engine)
-
-
-def default_trimmer_factory(enable_trim: bool) -> PageTrimmer:
- return LayoutTrimmer() if enable_trim else IdentityTrimmer()
-
-
-class PageProcessingStage:
- """Async stage that performs LLM-based page splitting and optional trimming."""
-
- def __init__(
- self,
- *,
- splitter: PageSplitter,
- trimmer: PageTrimmer,
- concurrency_limit: int,
- ) -> None:
- self._splitter = splitter
- self._trimmer = trimmer
- self._semaphore = asyncio.Semaphore(concurrency_limit)
-
- def spawn_workers(
- self,
- *,
- count: int,
- raster_queue: asyncio.Queue[RasterTask | None],
- processed_queue: asyncio.Queue[PageSplitGroup | None],
- ) -> list[asyncio.Task[None]]:
- return [
- asyncio.create_task(self._worker(raster_queue, processed_queue)) for _ in range(count)
- ]
-
- async def _worker(
- self,
- raster_queue: asyncio.Queue[RasterTask | None],
- processed_queue: asyncio.Queue[PageSplitGroup | None],
- ) -> None:
- while True:
- task = await raster_queue.get()
- if task is None:
- raster_queue.task_done()
- break
-
- try:
- image = Image.open(BytesIO(task.png_bytes))
- except Exception as exc:
- logger.error(
- f"Failed to decode PNG bytes for pdf_id={getattr(task, 'pdf_id', 'unknown')} page={getattr(task, 'page_index', 'unknown')}: {exc}",
- )
- await processed_queue.put(
- PageSplitGroup(
- pdf_id=task.pdf_id,
- pdf_path=task.pdf_path,
- page_index=task.page_index,
- images=[],
- )
- )
- raster_queue.task_done()
- continue
-
- async with self._semaphore:
- try:
- splits = await self._splitter.split(image)
- except Exception as exc:
- logger.error(
- f"Split failed (pdf_id={task.pdf_id}, page={task.page_index}): {exc}",
- )
- splits = [image]
-
- results: list[Image.Image] = []
- for split in splits:
- try:
- trimmed = await self._trimmer.trim(split)
- except Exception as exc:
- logger.error(
- f"Trim failed (pdf_id={task.pdf_id}, page={task.page_index}): {exc}",
- )
- trimmed = split
- results.append(trimmed)
-
- await processed_queue.put(
- PageSplitGroup(
- pdf_id=task.pdf_id,
- pdf_path=task.pdf_path,
- page_index=task.page_index,
- images=results,
- )
- )
- raster_queue.task_done()
-
-
-class SavingStage:
- """Saver that writes output images in deterministic order."""
-
- def __init__(self, output_dir: str, progress: ProgressReporter | None = None) -> None:
- self._output_dir = output_dir
- self._splits_counter: dict[int, int] = {}
- self._progress = progress
-
- @property
- def splits_counter(self) -> dict[int, int]:
- return self._splits_counter
-
- async def consume(self, queue: asyncio.Queue[PageSplitGroup | None]) -> None:
- os.makedirs(self._output_dir, exist_ok=True)
- pending: dict[int, dict[int, list[Image.Image]]] = {}
- next_expected: dict[int, int] = {}
-
- while True:
- group = await queue.get()
- if group is None:
- queue.task_done()
- break
-
- pending.setdefault(group.pdf_id, {})[group.page_index] = group.images
- next_expected.setdefault(group.pdf_id, 0)
- self._splits_counter.setdefault(group.pdf_id, 0)
-
- while next_expected[group.pdf_id] in pending[group.pdf_id]:
- images = pending[group.pdf_id].pop(next_expected[group.pdf_id])
- stem, _ = os.path.splitext(os.path.basename(group.pdf_path))
- pdf_stem = stem.replace(" ", "_")
-
- for image in images:
- logical_index = self._splits_counter[group.pdf_id]
- filename = f"{pdf_stem}_page_{logical_index:04d}.png"
- out_path = os.path.join(self._output_dir, filename)
- try:
- image.save(out_path, "PNG")
- except Exception as exc: # pragma: no cover
- logger.error(f"Failed saving {out_path}: {exc}")
- self._splits_counter[group.pdf_id] = logical_index + 1
- next_expected[group.pdf_id] += 1
- if self._progress:
- self._progress.increment()
-
- queue.task_done()
-
-
-def _parse_number_of_pages(llm_output: str) -> int:
- number_of_pages_str = extract_tag_from_llm_output(llm_output, tags="number_of_pages")
- try:
- if isinstance(number_of_pages_str, list):
- return int(number_of_pages_str[0]) if number_of_pages_str else 1
- return int(number_of_pages_str)
- except Exception:
- return 1
-
-
-PAGE_SPLIT_PROMPT = (
- "You are given an image of a scanned document. Your task is to decide whether the image "
- "actually contains ONE page or a TWO-PAGE SPREAD (i.e., two distinct adjacent pages captured together).\n\n"
- "Return ONLY 1 or 2 inside tags.\n\n"
- "Classify as 2 ONLY if there are clearly two separate full pages. Strong indicators of TWO pages: "
- "(a) visible central gutter or fold between pages, (b) duplicated page headers/footers or page numbers "
- "appearing twice (left & right), (c) two independent margin/edge boundaries, (d) overall wide aspect ratio "
- "where each half looks like a normal page.\n"
- "Keep as 1 if it is a single page that merely has: multiple text columns, sidebars, advertisements, "
- "tables, fold marks, marginal notes, decorative frames, or partial cropping of a neighboring page edge. "
- "Multiple columns alone DO NOT mean two pages.\n\n"
- "Edge cases: If unsure, output 1. If one page is only partially visible (e.g., a sliver of another page), "
- "still output 1.\n\n"
- "Format EXACTLY:\n\n1 or 2\n "
-)
-
-
-@dataclass(slots=True)
-class PdfPipelineConfig:
- engine: str
- output_dir: str
- dpi: int | None = 300
- batch_pages: int = 8
- queue_maxsize: int = 64
- raster_workers: int | None = None
- page_workers: int | None = None
- llm_concurrency_limit: int = 64
- trim: bool = True
- splitter_factory: PageSplitterFactory | None = None
- trimmer_factory: PageTrimmerFactory | None = None
- fallback_dpi: int = DEFAULT_RASTER_DPI
-
-
-class PdfPipeline:
- """High-level coordinator for staged PDF/image processing."""
-
- def __init__(
- self,
- config: PdfPipelineConfig,
- *,
- progress_reporter: ProgressReporter | None = None,
- ) -> None:
- self._config = config
- self._progress = progress_reporter
-
- async def run(
- self,
- *,
- pdf_paths: Iterable[str],
- image_dir: str | None = None,
- image_paths: Iterable[str] | None = None,
- ) -> None:
- pdf_files = self._dedupe_pdfs(list(pdf_paths))
- image_files = self._collect_images(image_dir, image_paths)
-
- if not pdf_files and not image_files:
- logger.warning("No valid PDF or image inputs provided to pipeline.")
- return
-
- os.makedirs(self._config.output_dir, exist_ok=True)
-
- progress_started = False
- total_units = self._estimate_total_work(pdf_files, image_files)
- if self._progress and total_units:
- self._progress.start(total_units)
- progress_started = True
-
- raster_workers = self._config.raster_workers or max(1, (os.cpu_count() or 2) // 2)
- page_workers = self._config.page_workers or max(2, os.cpu_count() or 2)
-
- raster_queue: asyncio.Queue[RasterTask | None] = asyncio.Queue(
- maxsize=self._config.queue_maxsize
- )
- processed_queue: asyncio.Queue[PageSplitGroup | None] = asyncio.Queue(
- maxsize=self._config.queue_maxsize
- )
-
- raster_stage = RasterizationStage(
- dpi=self._config.dpi,
- batch_pages=self._config.batch_pages,
- max_workers=raster_workers,
- fallback_dpi=self._config.fallback_dpi,
- )
- image_stage = ImageIngestionStage()
- splitter_factory = self._config.splitter_factory or default_splitter_factory
- trimmer_factory = self._config.trimmer_factory or default_trimmer_factory
-
- splitter = splitter_factory(self._config.engine)
- trimmer = trimmer_factory(self._config.trim)
- processor_stage = PageProcessingStage(
- splitter=splitter,
- trimmer=trimmer,
- concurrency_limit=self._config.llm_concurrency_limit,
- )
- saver_stage = SavingStage(self._config.output_dir, progress=self._progress)
-
- try:
- producer_tasks: list[asyncio.Task[None]] = []
- if pdf_files:
- producer_tasks.append(
- asyncio.create_task(
- raster_stage.produce(pdf_files, raster_queue, start_pdf_id=0)
- )
- )
- if image_files:
- producer_tasks.append(
- asyncio.create_task(
- image_stage.produce(
- image_files,
- raster_queue,
- start_pdf_id=len(pdf_files),
- )
- )
- )
-
- worker_tasks = processor_stage.spawn_workers(
- count=page_workers, raster_queue=raster_queue, processed_queue=processed_queue
- )
- saver_task = asyncio.create_task(saver_stage.consume(processed_queue))
-
- if producer_tasks:
- await asyncio.gather(*producer_tasks)
- for _ in worker_tasks:
- await raster_queue.put(None)
-
- await raster_queue.join()
- await processed_queue.put(None)
- await asyncio.gather(*worker_tasks)
- await processed_queue.join()
- await saver_task
-
- logger.info(
- f"Pipeline complete. PDFs processed: {len(pdf_files)} | Images processed: {len(image_files)} "
- f"| Total pages: {sum(saver_stage.splits_counter.values())}"
- )
- finally:
- if progress_started and self._progress:
- self._progress.close()
-
- def _estimate_total_work(self, pdf_files: Sequence[str], image_files: Sequence[str]) -> int:
- total_units = len(image_files)
- for path in pdf_files:
- try:
- with fitz.open(path) as doc:
- total_units += doc.page_count
- except Exception as exc: # pragma: no cover - defensive logging
- logger.debug(f"Failed to open {path} for progress estimation: {exc}")
- return total_units
-
- def _dedupe_pdfs(self, pdf_paths: list[str]) -> list[str]:
- if not pdf_paths:
- return []
- pdf_files = [
- path for path in pdf_paths if os.path.isfile(path) and path.lower().endswith(".pdf")
- ]
- if pdf_paths and not pdf_files:
- logger.warning("No valid PDF files found; skipping PDF rasterization stage.")
- seen: set[str] = set()
- unique: list[str] = []
- for path in pdf_files:
- if path not in seen:
- seen.add(path)
- unique.append(path)
- return unique
-
- def _collect_images(
- self,
- image_dir: str | None,
- image_paths: Iterable[str] | None,
- ) -> list[str]:
- image_files: list[str] = []
- if image_dir:
- if os.path.isdir(image_dir):
- image_files.extend(_collect_image_paths(image_dir))
- if not image_files:
- logger.warning(
- "Image directory provided but no supported image files were found; skipping image stage."
- )
- else:
- logger.warning(f"Image directory does not exist or is not a directory: {image_dir}")
-
- if image_paths:
- for image_path in image_paths:
- if not os.path.isfile(image_path):
- logger.warning(f"Image path does not exist or is not a file: {image_path}")
- continue
- ext = os.path.splitext(image_path)[1].lower()
- if ext not in SUPPORTED_IMAGE_EXTENSIONS:
- logger.warning(
- f"Unsupported image extension '{ext}' for path {image_path}; skipping."
- )
- continue
- image_files.append(image_path)
-
- seen: set[str] = set()
- unique: list[str] = []
- for path in image_files:
- if path not in seen:
- seen.add(path)
- unique.append(path)
- return unique
-
-
-async def run_pdf_pipeline(
- pdf_paths: Iterable[str],
- output_dir: str,
- engine: str,
- dpi: int | None = 300,
- batch_pages: int = 8,
- queue_maxsize: int = 64,
- raster_workers: int | None = None,
- page_workers: int | None = None,
- llm_concurrency_limit: int = 64,
- trim: bool = True,
- image_dir: str | None = None,
- image_paths: Iterable[str] | None = None,
- splitter_factory: PageSplitterFactory | None = None,
- trimmer_factory: PageTrimmerFactory | None = None,
- progress_reporter: ProgressReporter | None = None,
-) -> None:
- config = PdfPipelineConfig(
- engine=engine,
- output_dir=output_dir,
- dpi=dpi,
- batch_pages=batch_pages,
- queue_maxsize=queue_maxsize,
- raster_workers=raster_workers,
- page_workers=page_workers,
- llm_concurrency_limit=llm_concurrency_limit,
- trim=trim,
- splitter_factory=splitter_factory,
- trimmer_factory=trimmer_factory,
- )
- pipeline = PdfPipeline(config, progress_reporter=progress_reporter)
- try:
- await pipeline.run(pdf_paths=pdf_paths, image_dir=image_dir, image_paths=image_paths)
- finally:
- try:
- await shutdown_layout_clients()
- finally:
- await shutdown_llm_clients()
diff --git a/workdir b/workdir
new file mode 120000
index 0000000..ad1d871
--- /dev/null
+++ b/workdir
@@ -0,0 +1 @@
+/data1/sina/historychat_workdir
\ No newline at end of file