chore: import upstream snapshot with attribution
Main / Python 3.11 - Docs (push) Waiting to run
Main / Python 3.11 - Build (push) Waiting to run
Main / Python 3.11 - Lint (push) Waiting to run
Main / Python 3.11 - Style (push) Waiting to run
Main / Python 3.11 - Test (push) Waiting to run
Main / GPU CI (push) Blocked by required conditions
Main / Release (push) Blocked by required conditions
Main / Build and Push Docker Images (push) Blocked by required conditions

This commit is contained in:
wehub-resource-sync
2026-07-13 13:27:09 +08:00
commit 917eedffcf
430 changed files with 86086 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.github
.mypy_cache
.pytest_cache
.venv
__pycache__
*.egg-info
# Let's not copy any bash scripts from the scripts folder over, otherwise trashing the docker image too much with recent changes
scripts/*.sh
scripts/**/*.sh
# Nor copy any olmocr bench files
olmOCR-bench/
olmOCR-bench*/
html_templates*/
olmocr-synthmix-*/
+168
View File
@@ -0,0 +1,168 @@
# Contributing
Thanks for considering contributing! Please read this document to learn the various ways you can contribute to this project and how to go about doing it.
## Bug reports and feature requests
### Did you find a bug?
First, do [a quick search](https://github.com/allenai/olmocrissues) to see whether your issue has already been reported.
If your issue has already been reported, please comment on the existing issue.
Otherwise, open [a new GitHub issue](https://github.com/allenai/olmocrissues). Be sure to include a clear title
and description. The description should include as much relevant information as possible. The description should
explain how to reproduce the erroneous behavior as well as the behavior you expect to see. Ideally you would include a
code sample or an executable test case demonstrating the expected behavior.
### Do you have a suggestion for an enhancement or new feature?
We use GitHub issues to track feature requests. Before you create a feature request:
* Make sure you have a clear idea of the enhancement you would like. If you have a vague idea, consider discussing
it first on a GitHub issue.
* Check the documentation to make sure your feature does not already exist.
* Do [a quick search](https://github.com/allenai/olmocrissues) to see whether your feature has already been suggested.
When creating your request, please:
* Provide a clear title and description.
* Explain why the enhancement would be useful. It may be helpful to highlight the feature in other libraries.
* Include code examples to demonstrate how the enhancement would be used.
## Making a pull request
When you're ready to contribute code to address an open issue, please follow these guidelines to help us be able to review your pull request (PR) quickly.
1. **Initial setup** (only do this once)
<details><summary>Expand details 👇</summary><br/>
If you haven't already done so, please [fork](https://help.github.com/en/enterprise/2.13/user/articles/fork-a-repo) this repository on GitHub.
Then clone your fork locally with
git clone https://github.com/USERNAME/olmocrgit
or
git clone git@github.com:USERNAME/olmocrgit
At this point the local clone of your fork only knows that it came from *your* repo, github.com/USERNAME/olmocrgit, but doesn't know anything the *main* repo, [https://github.com/allenai/oolmocrit](https://github.com/allenai/ololmocrYou can see this by running
git remote -v
which will output something like this:
origin https://github.com/USERNAME/olmocrgit (fetch)
origin https://github.com/USERNAME/olmocrgit (push)
This means that your local clone can only track changes from your fork, but not from the main repo, and so you won't be able to keep your fork up-to-date with the main repo over time. Therefore you'll need to add another "remote" to your clone that points to [https://github.com/allenai/olmocrgit](https://github.com/allenai/oolmocr To do this, run the following:
git remote add upstream https://github.com/allenai/olmocrgit
Now if you do `git remote -v` again, you'll see
origin https://github.com/USERNAME/olmocrgit (fetch)
origin https://github.com/USERNAME/olmocrgit (push)
upstream https://github.com/allenai/olmocrgit (fetch)
upstream https://github.com/allenai/olmocrgit (push)
Finally, you'll need to create a Python 3 virtual environment suitable for working on this project. There a number of tools out there that making working with virtual environments easier.
The most direct way is with the [`venv` module](https://docs.python.org/3.7/library/venv.html) in the standard library, but if you're new to Python or you don't already have a recent Python 3 version installed on your machine,
we recommend [Miniconda](https://docs.conda.io/en/latest/miniconda.html).
On Mac, for example, you can install Miniconda with [Homebrew](https://brew.sh/):
brew install miniconda
Then you can create and activate a new Python environment by running:
conda create -n olmocrpython=3.9
conda activate olmocr
Once your virtual environment is activated, you can install your local clone in "editable mode" with
pip install -U pip setuptools wheel
pip install -e .[dev]
The "editable mode" comes from the `-e` argument to `pip`, and essential just creates a symbolic link from the site-packages directory of your virtual environment to the source code in your local clone. That way any changes you make will be immediately reflected in your virtual environment.
</details>
2. **Ensure your fork is up-to-date**
<details><summary>Expand details 👇</summary><br/>
Once you've added an "upstream" remote pointing to [https://github.com/allenai/python-package-temlate.git](https://github.com/allenai/olmocr, keeping your fork up-to-date is easy:
git checkout main # if not already on main
git pull --rebase upstream main
git push
</details>
3. **Create a new branch to work on your fix or enhancement**
<details><summary>Expand details 👇</summary><br/>
Committing directly to the main branch of your fork is not recommended. It will be easier to keep your fork clean if you work on a separate branch for each contribution you intend to make.
You can create a new branch with
# replace BRANCH with whatever name you want to give it
git checkout -b BRANCH
git push -u origin BRANCH
</details>
4. **Test your changes**
<details><summary>Expand details 👇</summary><br/>
Our continuous integration (CI) testing runs [a number of checks](https://github.com/allenai/olmocractions) for each pull request on [GitHub Actions](https://github.com/features/actions). You can run most of these tests locally, which is something you should do *before* opening a PR to help speed up the review process and make it easier for us.
First, you should run [`isort`](https://github.com/PyCQA/isort) and [`black`](https://github.com/psf/black) to make sure you code is formatted consistently.
Many IDEs support code formatters as plugins, so you may be able to setup isort and black to run automatically everytime you save.
For example, [`black.vim`](https://github.com/psf/black/tree/master/plugin) will give you this functionality in Vim. But both `isort` and `black` are also easy to run directly from the command line.
Just run this from the root of your clone:
isort .
black .
Our CI also uses [`ruff`](https://github.com/astral-sh/ruff) to lint the code base and [`mypy`](http://mypy-lang.org/) for type-checking. You should run both of these next with
ruff check .
and
mypy .
We also strive to maintain high test coverage, so most contributions should include additions to [the unit tests](https://github.com/allenai/olmocrtree/main/tests). These tests are run with [`pytest`](https://docs.pytest.org/en/latest/), which you can use to locally run any test modules that you've added or changed.
For example, if you've fixed a bug in `olmocra/b.py`, you can run the tests specific to that module with
pytest -v tests/a/b_test.py
If your contribution involves additions to any public part of the API, we require that you write docstrings
for each function, method, class, or module that you add.
See the [Writing docstrings](#writing-docstrings) section below for details on the syntax.
You should test to make sure the API documentation can build without errors by running
make docs
If the build fails, it's most likely due to small formatting issues. If the error message isn't clear, feel free to comment on this in your pull request.
And finally, please update the [CHANGELOG](https://github.com/allenai/olmocrblob/main/CHANGELOG.md) with notes on your contribution in the "Unreleased" section at the top.
After all of the above checks have passed, you can now open [a new GitHub pull request](https://github.com/allenai/olmocrpulls).
Make sure you have a clear description of the problem and the solution, and include a link to relevant issues.
We look forward to reviewing your PR!
</details>
### Writing docstrings
We use [Sphinx](https://www.sphinx-doc.org/en/master/index.html) to build our API docs, which automatically parses all docstrings
of public classes and methods using the [autodoc](https://www.sphinx-doc.org/en/master/usage/extensions/autodoc.html) extension.
Please refer to autoc's documentation to learn about the docstring syntax.
+46
View File
@@ -0,0 +1,46 @@
name: 🐛 Bug Report
description: Create a report to help us reproduce and fix the bug
labels: 'bug'
body:
- type: markdown
attributes:
value: >
#### Before submitting a bug, please make sure the issue hasn't been already addressed by searching through [the existing and past issues](https://github.com/allenai/olmocr/issues?q=is%3Aissue+sort%3Acreated-desc+).
- type: textarea
attributes:
label: 🐛 Describe the bug
description: |
Please provide a clear and concise description of what the bug is.
If relevant, add a minimal example so that we can reproduce the error by running the code. It is very important for the snippet to be as succinct (minimal) as possible, so please take time to trim down any irrelevant code to help us debug efficiently. We are going to copy-paste your code and we expect to get the same result as you did: avoid any external data, and include the relevant imports, etc. For example:
```python
# All necessary imports at the beginning
import olmocr
# A succinct reproducing example trimmed down to the essential parts:
assert False is True, "Oh no!"
```
If the code is too long (hopefully, it isn't), feel free to put it in a public gist and link it in the issue: https://gist.github.com.
Please also paste or describe the results you observe instead of the expected results. If you observe an error, please paste the error message including the **full** traceback of the exception. It may be relevant to wrap error messages in ```` ```triple quotes blocks``` ````.
placeholder: |
A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
attributes:
label: Versions
description: |
Please run the following and paste the output below.
```sh
python --version && pip freeze
```
validations:
required: true
- type: markdown
attributes:
value: >
Thanks for contributing 🎉!
+21
View File
@@ -0,0 +1,21 @@
name: 📚 Documentation
description: Report an issue related to https://olmocr.readthedocs.io/latest
labels: 'documentation'
body:
- type: textarea
attributes:
label: 📚 The doc issue
description: >
A clear and concise description of what content in https://olmocr.readthedocs.io/latest is an issue.
validations:
required: true
- type: textarea
attributes:
label: Suggest a potential alternative/fix
description: >
Tell us how we could improve the documentation in this regard.
- type: markdown
attributes:
value: >
Thanks for contributing 🎉!
@@ -0,0 +1,26 @@
name: 🚀 Feature request
description: Submit a proposal/request for a new feature
labels: 'feature request'
body:
- type: textarea
attributes:
label: 🚀 The feature, motivation and pitch
description: >
A clear and concise description of the feature proposal. Please outline the motivation for the proposal. Is your feature request related to a specific problem? e.g., *"I'm working on X and would like Y to be possible"*. If this is related to another GitHub issue, please link here too.
validations:
required: true
- type: textarea
attributes:
label: Alternatives
description: >
A description of any alternative solutions or features you've considered, if any.
- type: textarea
attributes:
label: Additional context
description: >
Add any other context or screenshots about the feature request.
- type: markdown
attributes:
value: >
Thanks for contributing 🎉!
+58
View File
@@ -0,0 +1,58 @@
name: Python virtualenv
description: Set up a Python virtual environment with caching
inputs:
python-version:
description: The Python version to use
required: true
cache-prefix:
description: Update this to invalidate the cache
required: true
default: v0
runs:
using: composite
steps:
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: ${{ inputs.python-version }}
- shell: bash
run: |
# Install prerequisites.
pip install --upgrade pip setuptools wheel virtualenv
- shell: bash
run: |
# Get the exact Python version to use in the cache key.
echo "PYTHON_VERSION=$(python --version)" >> $GITHUB_ENV
- uses: actions/cache@v3
id: virtualenv-cache
with:
path: .venv
key: ${{ inputs.cache-prefix }}-${{ runner.os }}-${{ env.PYTHON_VERSION }}-${{ hashFiles('pyproject.toml') }}
- if: steps.virtualenv-cache.outputs.cache-hit != 'true'
shell: bash
run: |
# Set up virtual environment without cache hit.
test -d .venv || virtualenv -p $(which python) --copies --reset-app-data .venv
. .venv/bin/activate
pip install -e .[dev]
pip install -e .[bench]
- if: steps.virtualenv-cache.outputs.cache-hit == 'true'
shell: bash
run: |
# Set up virtual environment from cache hit.
. .venv/bin/activate
pip install --no-deps -e .[dev]
pip install --no-deps -e .[bench]
- shell: bash
run: |
# Show environment info.
. .venv/bin/activate
echo "✓ Installed $(python --version) virtual environment to $(which python)"
echo "Packages:"
pip freeze
+11
View File
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 10
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
+18
View File
@@ -0,0 +1,18 @@
<!-- To ensure we can review your pull request promptly please complete this template entirely. -->
<!-- Please reference the issue number here. You can replace "Fixes" with "Closes" if it makes more sense. -->
Fixes #
Changes proposed in this pull request:
<!-- Please list all changes/additions here. -->
-
## Before submitting
<!-- Please complete this checklist BEFORE submitting your PR to speed along the review process. -->
- [ ] I've read and followed all steps in the [Making a pull request](https://github.com/allenai/olmocr/blob/main/.github/CONTRIBUTING.md#making-a-pull-request)
section of the `CONTRIBUTING` docs.
- [ ] I've updated or added any relevant docstrings following the syntax described in the
[Writing docstrings](https://github.com/allenai/olmocr/blob/main/.github/CONTRIBUTING.md#writing-docstrings) section of the `CONTRIBUTING` docs.
- [ ] If this PR fixes a bug, I've added a test that will fail without my fix.
- [ ] If this PR adds a new feature, I've added tests that sufficiently cover my new functionality.
+321
View File
@@ -0,0 +1,321 @@
name: Main
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
pull_request:
branches:
- main
push:
branches:
- main
tags:
- "v*.*.*"
env:
# Change this to invalidate existing cache.
CACHE_PREFIX: v0
PYTHONPATH: ./
jobs:
checks:
name: Python ${{ matrix.python }} - ${{ matrix.task.name }}
runs-on: large-olmocr-runner
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python: ["3.11"]
task:
- name: Test
run: |
playwright install chromium
pytest -v --color=yes -m "not nonci" tests/
include:
- python: "3.11"
task:
name: Lint
run: ruff check .
# Removing mypy for now, as it isn't handling async things correctly and crashing
# - python: "3.11"
# task:
# name: Type check
# run: mypy .
- python: "3.11"
task:
name: Build
run: |
python -m build
- python: "3.11"
task:
name: Style
run: |
isort --check .
black --check .
- python: "3.11"
task:
name: Docs
run: cd docs && make html
steps:
- uses: actions/checkout@v3
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends poppler-utils
- name: Setup Python environment
uses: ./.github/actions/setup-venv
with:
python-version: ${{ matrix.python }}
cache-prefix: ${{ env.CACHE_PREFIX }}
- name: Restore mypy cache
if: matrix.task.name == 'Type check'
uses: actions/cache@v3
with:
path: .mypy_cache
key: mypy-${{ env.CACHE_PREFIX }}-${{ runner.os }}-${{ matrix.python }}-${{ hashFiles('*requirements.txt') }}-${{ github.ref }}-${{ github.sha }}
restore-keys: |
mypy-${{ env.CACHE_PREFIX }}-${{ runner.os }}-${{ matrix.python }}-${{ hashFiles('*requirements.txt') }}-${{ github.ref }}
mypy-${{ env.CACHE_PREFIX }}-${{ runner.os }}-${{ matrix.python }}-${{ hashFiles('*requirements.txt') }}
- name: ${{ matrix.task.name }}
run: |
. .venv/bin/activate
${{ matrix.task.run }}
- name: Upload package distribution files
if: matrix.task.name == 'Build'
uses: actions/upload-artifact@v4
with:
name: package
path: dist
- name: Clean up
if: always()
run: |
. .venv/bin/activate
pip uninstall -y olmocr
gpu_checks:
name: GPU CI
runs-on: large-olmocr-runner
timeout-minutes: 15
needs: [checks]
env:
BEAKER_TOKEN: ${{ secrets.BEAKER_TOKEN }}
BEAKER_IMAGE: jakep/olmocr-gpu-ci
BEAKER_BUDGET: ai2/oe-base
BEAKER_WORKSPACE: ai2/olmocr
steps:
- name: Determine current commit SHA (pull request)
if: github.event_name == 'pull_request'
run: |
echo "COMMIT_SHA=${{ github.event.pull_request.head.sha }}" >> $GITHUB_ENV
- name: Determine current commit SHA (push)
if: github.event_name != 'pull_request'
run: |
echo "COMMIT_SHA=$GITHUB_SHA" >> $GITHUB_ENV
- name: GPU Tests
uses: allenai/beaker-run-action@v1.2
if: env.BEAKER_TOKEN != ''
with:
spec: |
version: v2
description: GPU Tests
budget: ${{ env.BEAKER_BUDGET }}
tasks:
- name: tests
image:
beaker: ${{ env.BEAKER_IMAGE }}
context:
priority: normal
preemptible: true
resources:
gpuCount: 1
constraints:
cluster:
- ai2/jupiter-cirrascale-2
- ai2/neptune-cirrascale
- ai2/saturn-cirrascale
- ai2/ceres-cirrascale
envVars:
- name: GIT_REVISION
value: ${{ env.COMMIT_SHA }}
entrypoint: ["/bin/bash"]
command: ["./gpu-ci-script.sh"]
result:
path: /unused
token: ${{ env.BEAKER_TOKEN }}
workspace: ${{ env.BEAKER_WORKSPACE }}
release:
name: Release
runs-on: large-olmocr-runner
needs: [checks, gpu_checks]
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install requirements
run: |
pip install --upgrade pip setuptools wheel build
pip install -e .[dev]
pip install -e .[bench]
- name: Prepare environment
run: |
echo "RELEASE_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Download package distribution files
uses: actions/download-artifact@v4
with:
name: package
path: dist
- name: Generate release notes
run: |
python scripts/release_notes.py > ${{ github.workspace }}-RELEASE_NOTES.md
- name: Publish package to PyPI
run: |
twine upload -u '${{ secrets.PYPI_USERNAME }}' -p '${{ secrets.PYPI_PASSWORD }}' dist/*
- name: Publish GitHub release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
body_path: ${{ github.workspace }}-RELEASE_NOTES.md
prerelease: ${{ contains(env.TAG, 'rc') }}
files: |
dist/*
docker-build:
name: Build and Push Docker Images
runs-on: large-olmocr-runner
needs: [release]
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: read
packages: write
env:
REGISTRY: docker.io
IMAGE_NAME: alleninstituteforai/olmocr
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Free up disk space
run: |
sudo rm -rf /usr/share/dotnet \
/usr/local/lib/android \
/opt/ghc \
/opt/hostedtoolcache/CodeQL
sudo docker system prune -af
sudo apt-get -y autoremove
sudo apt-get -y autoclean
df -h
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata for base image
id: meta-base
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
flavor: |
latest=true
- name: Build and push base Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta-base.outputs.tags }}
labels: ${{ steps.meta-base.outputs.labels }}
platforms: linux/amd64
outputs: type=registry
no-cache: true
- name: Extract metadata for image with model
id: meta-with-model
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=tag,suffix=-with-model
type=semver,pattern={{version}}-with-model
type=semver,pattern={{major}}.{{minor}}-with-model
flavor: |
latest=auto
suffix=-with-model,onlatest=true
- name: Build and push Docker image with model
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.with-model
push: true
tags: ${{ steps.meta-with-model.outputs.tags }}
labels: ${{ steps.meta-with-model.outputs.labels }}
platforms: linux/amd64
outputs: type=registry
# jakep: push to beaker can't work because of limitted disk space on these runners
# jakep: (you can try by setting load: true above, but you'll need a larger runner)
# - name: Setup Beaker CLI
# uses: allenai/setup-beaker@v2
# with:
# token: ${{ secrets.BEAKER_TOKEN }}
# version: latest
# - name: Debug Docker images
# run: docker images
# - name: Push to Beaker
# env:
# BEAKER_TOKEN: ${{ secrets.BEAKER_TOKEN }}
# run: |
# VERSION=${{ steps.meta.outputs.version }}
# beaker image create \
# --name "olmocr-inference-$VERSION" \
# --workspace ai2/olmocr \
# alleninstituteforai/olmocr:$VERSION
- name: Clean up after build
if: always()
run: |
docker system prune -af --volumes
df -h
+29
View File
@@ -0,0 +1,29 @@
name: PR Checks
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
pull_request:
branches:
- main
paths:
- 'olmocr/**'
jobs:
changelog:
name: CHANGELOG
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Check that CHANGELOG has been updated
run: |
# If this step fails, this means you haven't updated the CHANGELOG.md
# file with notes on your contribution.
git diff --name-only $(git merge-base origin/main HEAD) | grep '^CHANGELOG.md$' && echo "Thanks for helping keep our CHANGELOG up-to-date!"
+87
View File
@@ -0,0 +1,87 @@
# ml stuff
wandb/
*histogram.png
*.json
dolma_previews/*
s2_previews/*
gnarly_previews/*
s2orc_previews/*
s2orc_previews_3200/*
sample200_vllm/*
sample200_sglang/*
pdelfin_testset/*
localworkspace/*
math_data/*
math_data_big/*
gpt4otestset/*
old_train/
gpt4otestset_output/*
pdfs/*
olmOCR-bench/*
table_data*/
/synth*/
dolma_samples/*
old_train/
filtered_items/
filtered_items_prefilter/
augraphy_cache/
/*.html
html_templates*/
olmocr-synthmix*/
scoreelo.csv
debug.log
birrpipeline-debug.log
beakerpipeline-debug.log
olmocr-pipeline-debug.log
# build artifacts
.eggs/
.mypy_cache
*.egg-info/
build/
dist/
pip-wheel-metadata/
# dev tools
.envrc
.python-version
.idea
.venv/
.vscode/
/*.iml
pyrightconfig.json
# jupyter notebooks
.ipynb_checkpoints
# miscellaneous
.cache/
doc/_build/
*.swp
.DS_Store
# python
*.pyc
*.pyo
__pycache__
# testing and continuous integration
.coverage
.pytest_cache/
.benchmarks
# documentation build artifacts
docs/build
site/
+14
View File
@@ -0,0 +1,14 @@
version: 2
sphinx:
configuration: docs/source/conf.py
fail_on_warning: true
python:
version: "3.8"
install:
- method: pip
path: .
extra_requirements:
- dev
+116
View File
@@ -0,0 +1,116 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## [v0.4.27](https://github.com/allenai/olmocr/releases/tag/v0.4.27) - 2026-03-12
## [v0.4.26](https://github.com/allenai/olmocr/releases/tag/v0.4.26) - 2026-03-06
## [v0.4.25](https://github.com/allenai/olmocr/releases/tag/v0.4.25) - 2026-01-25
## [v0.4.24](https://github.com/allenai/olmocr/releases/tag/v0.4.24) - 2026-01-23
## [v0.4.23](https://github.com/allenai/olmocr/releases/tag/v0.4.23) - 2026-01-23
## [v0.4.22](https://github.com/allenai/olmocr/releases/tag/v0.4.22) - 2026-01-23
## [v0.4.21](https://github.com/allenai/olmocr/releases/tag/v0.4.21) - 2026-01-23
## [v0.4.20](https://github.com/allenai/olmocr/releases/tag/v0.4.20) - 2026-01-20
## [v0.4.19](https://github.com/allenai/olmocr/releases/tag/v0.4.19) - 2026-01-20
## [v0.4.18](https://github.com/allenai/olmocr/releases/tag/v0.4.18) - 2026-01-20
## [v0.4.17](https://github.com/allenai/olmocr/releases/tag/v0.4.17) - 2026-01-20
## [v0.4.16](https://github.com/allenai/olmocr/releases/tag/v0.4.16) - 2026-01-12
## [v0.4.15](https://github.com/allenai/olmocr/releases/tag/v0.4.15) - 2026-01-09
## [v0.4.14](https://github.com/allenai/olmocr/releases/tag/v0.4.14) - 2026-01-08
## [v0.4.13](https://github.com/allenai/olmocr/releases/tag/v0.4.13) - 2026-01-08
## [v0.4.12](https://github.com/allenai/olmocr/releases/tag/v0.4.12) - 2025-12-10
## [v0.4.11](https://github.com/allenai/olmocr/releases/tag/v0.4.11) - 2025-12-10
## [v0.4.10](https://github.com/allenai/olmocr/releases/tag/v0.4.10) - 2025-12-09
## [v0.4.9](https://github.com/allenai/olmocr/releases/tag/v0.4.9) - 2025-12-09
## [v0.4.7](https://github.com/allenai/olmocr/releases/tag/v0.4.7) - 2025-12-01
## [v0.4.6](https://github.com/allenai/olmocr/releases/tag/v0.4.6) - 2025-11-17
## [v0.4.5](https://github.com/allenai/olmocr/releases/tag/v0.4.5) - 2025-11-14
## [v0.4.4](https://github.com/allenai/olmocr/releases/tag/v0.4.4) - 2025-11-04
## [v0.4.3](https://github.com/allenai/olmocr/releases/tag/v0.4.3) - 2025-10-31
## [v0.4.2](https://github.com/allenai/olmocr/releases/tag/v0.4.2) - 2025-10-22
## [v0.4.1](https://github.com/allenai/olmocr/releases/tag/v0.4.1) - 2025-10-22
## [v0.4.0](https://github.com/allenai/olmocr/releases/tag/v0.4.0) - 2025-10-22
## [v0.3.9](https://github.com/allenai/olmocr/releases/tag/v0.3.9) - 2025-10-07
## [v0.3.8](https://github.com/allenai/olmocr/releases/tag/v0.3.8) - 2025-10-06
## [v0.3.7](https://github.com/allenai/olmocr/releases/tag/v0.3.7) - 2025-10-06
## [v0.3.6](https://github.com/allenai/olmocr/releases/tag/v0.3.6) - 2025-09-29
## [v0.3.4](https://github.com/allenai/olmocr/releases/tag/v0.3.4) - 2025-08-31
## [v0.3.3](https://github.com/allenai/olmocr/releases/tag/v0.3.3) - 2025-08-15
## [v0.3.2](https://github.com/allenai/olmocr/releases/tag/v0.3.2) - 2025-08-14
## [v0.3.1](https://github.com/allenai/olmocr/releases/tag/v0.3.1) - 2025-08-14
## [v0.3.0](https://github.com/allenai/olmocr/releases/tag/v0.3.0) - 2025-08-13
## [v0.2.3](https://github.com/allenai/olmocr/releases/tag/v0.2.3) - 2025-08-04
## [v0.2.2](https://github.com/allenai/olmocr/releases/tag/v0.2.2) - 2025-08-04
## [v0.2.1](https://github.com/allenai/olmocr/releases/tag/v0.2.1) - 2025-07-23
## [v0.2.0](https://github.com/allenai/olmocr/releases/tag/v0.2.0) - 2025-07-23
## [v0.1.76](https://github.com/allenai/olmocr/releases/tag/v0.1.76) - 2025-06-23
## [v0.1.75](https://github.com/allenai/olmocr/releases/tag/v0.1.75) - 2025-06-17
## [v0.1.74](https://github.com/allenai/olmocr/releases/tag/v0.1.74) - 2025-06-17
## [v0.1.73](https://github.com/allenai/olmocr/releases/tag/v0.1.73) - 2025-06-17
## [v0.1.72](https://github.com/allenai/olmocr/releases/tag/v0.1.72) - 2025-06-17
## [v0.1.71](https://github.com/allenai/olmocr/releases/tag/v0.1.71) - 2025-05-30
## [v0.1.70](https://github.com/allenai/olmocr/releases/tag/v0.1.70) - 2025-05-23
## [v0.1.69](https://github.com/allenai/olmocr/releases/tag/v0.1.69) - 2025-05-20
## [v0.1.68](https://github.com/allenai/olmocr/releases/tag/v0.1.68) - 2025-05-19
## [v0.1.60](https://github.com/allenai/olmocr/releases/tag/v0.1.60) - 2025-03-17
## [v0.1.58](https://github.com/allenai/olmocr/releases/tag/v0.1.58) - 2025-02-15
## [v0.1.53](https://github.com/allenai/olmocr/releases/tag/v0.1.53) - 2025-02-14
- Fixed git checks
- Added gemini and claude runners and a viewer.
+59
View File
@@ -0,0 +1,59 @@
FROM vllm/vllm-openai:v0.11.2
ENV PYTHON_VERSION=3.12
ENV CUSTOM_PY="/usr/bin/python${PYTHON_VERSION}"
# Workaround for installing fonts, which are needed for good rendering of documents
RUN DIST_PY=$(ls /usr/bin/python3.[0-9]* | sort -V | head -n1) && \
# If a python alternative scheme already exists, remember its value so we \
# can restore it later; otherwise, we will restore to CUSTOM_PY when we \
# are done. \
if update-alternatives --query python3 >/dev/null 2>&1; then \
ORIGINAL_PY=$(update-alternatives --query python3 | awk -F": " '/Value:/ {print $2}'); \
else \
ORIGINAL_PY=$CUSTOM_PY; \
fi && \
# ---- APT operations that require the distro python3 ------------------- \
echo "Temporarily switching python3 alternative to ${DIST_PY} so that APT scripts use the distrobuilt Python runtime." && \
update-alternatives --install /usr/bin/python3 python3 ${DIST_PY} 1 && \
update-alternatives --set python3 ${DIST_PY} && \
update-alternatives --install /usr/bin/python python ${DIST_PY} 1 && \
update-alternatives --set python ${DIST_PY} && \
apt-get update -y && \
apt-get remove -y python3-blinker || true && \
# Preseed the Microsoft Core Fonts EULA so the build is noninteractive \
echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" | debconf-set-selections && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
python3-apt \
update-notifier-common \
poppler-utils \
fonts-crosextra-caladea \
fonts-crosextra-carlito \
gsfonts \
lcdf-typetools \
ttf-mscorefonts-installer \
git git-lfs curl wget unzip && \
# ---- Restore the original / custom Python alternative ----------------- \
echo "Restoring python3 alternative to ${ORIGINAL_PY}" && \
update-alternatives --install /usr/bin/python3 python3 ${ORIGINAL_PY} 1 && \
update-alternatives --set python3 ${ORIGINAL_PY} && \
update-alternatives --install /usr/bin/python python ${ORIGINAL_PY} 1 || true && \
update-alternatives --set python ${ORIGINAL_PY} || true && \
# Ensure pip is available for the restored Python \
curl -sS https://bootstrap.pypa.io/get-pip.py | ${ORIGINAL_PY}
# keep the build context clean
WORKDIR /build
COPY . /build
# Needed to resolve setuptools dependencies
ENV UV_INDEX_STRATEGY="unsafe-best-match"
RUN uv pip install --system --no-cache ".[bench]"
RUN playwright install-deps
RUN playwright install chromium
RUN python3 -m olmocr.pipeline --help
# Override the vLLM base image's entrypoint to allow interactive bash access
ENTRYPOINT ["/bin/bash"]
+30
View File
@@ -0,0 +1,30 @@
# Build from the base olmocr image
FROM alleninstituteforai/olmocr:latest
# Allow specifying which model to include at build time
# Default to the latest olmOCR model
ARG MODEL_NAME=allenai/olmOCR-2-7B-1025-FP8
# Set model cache directory to a fixed location in the image
ENV HF_HOME=/opt/models
ENV TRANSFORMERS_CACHE=/opt/models
ENV OLMOCR_MODEL=${MODEL_NAME}
# Pre-download the olmOCR model into the image
# This adds ~16GB to the image size but eliminates runtime downloads
RUN python -c "from huggingface_hub import snapshot_download; \
import os; \
model = os.environ.get('OLMOCR_MODEL'); \
print(f'Downloading model: {model}'); \
snapshot_download(model, cache_dir='/opt/models/hub'); \
print(f'Model {model} successfully downloaded and cached in image')"
# Verify the model is present
RUN python -c "import os; \
model = os.environ.get('OLMOCR_MODEL').replace('/', '--'); \
model_path = f'/opt/models/hub/models--{model}'; \
assert os.path.exists(model_path), f'Model not found at {model_path}'; \
size = sum(os.path.getsize(os.path.join(dp, f)) for dp, dn, fn in os.walk(model_path) for f in fn) / (1024**3); \
print(f'Model size: {size:.2f} GB')"
# The entrypoint is already set to /bin/bash in the base image
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright {yyyy} {name of copyright owner}
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+17
View File
@@ -0,0 +1,17 @@
.PHONY : docs
docs :
rm -rf docs/build/
sphinx-autobuild -b html --watch olmocr/ docs/source/ docs/build/
.PHONY : run-checks
run-checks :
isort --check .
black --check .
ruff check .
mypy .
CUDA_VISIBLE_DEVICES='' pytest -v --color=yes --doctest-modules tests/ olmocr/
.PHONY : build
build :
rm -rf *.egg-info/
python -m build
+551
View File
@@ -0,0 +1,551 @@
<div align="center">
<img width="350" alt="olmocr-2-full@2x" src="https://github.com/user-attachments/assets/24f1b596-4059-46f1-8130-5d72dcc0b02e" />
<hr/>
</div>
<p align="center">
<a href="https://github.com/allenai/OLMo/blob/main/LICENSE">
<img alt="GitHub License" src="https://img.shields.io/github/license/allenai/OLMo">
</a>
<a href="https://github.com/allenai/olmocr/releases">
<img alt="GitHub release" src="https://img.shields.io/github/release/allenai/olmocr.svg">
</a>
<a href="https://arxiv.org/abs/2502.18443">
<img alt="Tech Report v1" src="https://img.shields.io/badge/Paper_v1-olmOCR-blue">
</a>
<a href="https://arxiv.org/abs/2510.19817">
<img alt="Tech Report v2" src="https://img.shields.io/badge/Paper_v2-olmOCR-blue">
</a>
<a href="https://olmocr.allenai.org">
<img alt="Demo" src="https://img.shields.io/badge/Ai2-Demo-F0529C">
</a>
<a href="https://discord.gg/sZq3jTNVNG">
<img alt="Discord" src="https://img.shields.io/badge/Discord%20-%20blue?style=flat&logo=discord&label=Ai2&color=%235B65E9">
</a>
</p>
A toolkit for converting PDFs and other image-based document formats into clean, readable, plain text format.
Try the online demo: [https://olmocr.allenai.org/](https://olmocr.allenai.org/)
Features:
- Convert PDF, PNG, and JPEG based documents into clean Markdown
- Support for equations, tables, handwriting, and complex formatting
- Automatically removes headers and footers
- Convert into text with a natural reading order, even in the presence of
figures, multi-column layouts, and insets
- Efficient, less than $200 USD per million pages converted
- (Based on a 7B parameter VLM, so it requires a GPU)
### News
- October 21, 2025 - v0.4.0 - [New model release](https://huggingface.co/allenai/olmOCR-2-7B-1025-FP8), boosts olmOCR-bench score by ~4 points using synthetic data and introduces RL training.
- August 13, 2025 - v0.3.0 - [New model release](https://huggingface.co/allenai/olmOCR-7B-0825-FP8), fixes auto-rotation detection, and hallucinations on blank documents.
- July 24, 2025 - v0.2.1 - [New model release](https://huggingface.co/allenai/olmOCR-7B-0725-FP8), scores 3 points higher on [olmOCR-Bench](https://github.com/allenai/olmocr/tree/main/olmocr/bench), also runs significantly faster because it's default FP8, and needs much fewer retries per document.
- July 23, 2025 - v0.2.0 - New cleaned up [trainer code](https://github.com/allenai/olmocr/tree/main/olmocr/train), makes it much simpler to train olmOCR models yourself.
- June 17, 2025 - v0.1.75 - Switch from sglang to vllm based inference pipeline, updated docker image to CUDA 12.8.
- May 23, 2025 - v0.1.70 - Official docker support and images are now available! [See Docker usage](#using-docker)
- May 19, 2025 - v0.1.68 - [olmOCR-Bench](https://github.com/allenai/olmocr/tree/main/olmocr/bench) launch, scoring 77.4. Launch includes 2 point performance boost in olmOCR pipeline due to bug fixes with prompts.
- Mar 17, 2025 - v0.1.60 - Performance improvements due to better temperature selection in sampling.
- Feb 25, 2025 - v0.1.58 - Initial public launch and demo.
### Benchmark
[**olmOCR-Bench**](https://github.com/allenai/olmocr/tree/main/olmocr/bench):
We also ship a comprehensive benchmark suite covering over 7,000 test cases across 1,400 documents to help measure performance of OCR systems.
<table>
<thead>
<tr>
<th></th>
<th>ArXiv</th>
<th>Old<br>scans<br>math</th>
<th>Tables</th>
<th>Old<br>scans</th>
<th>Headers<br>&<br>footers</th>
<th>Multi<br>column</th>
<th>Long<br>tiny<br>text</th>
<th>Base</th>
<th>Overall</th>
</tr>
</thead>
<tbody>
<tr>
<td>Mistral OCR API</td>
<td>77.2</td>
<td>67.5</td>
<td>60.6</td>
<td>29.3</td>
<td>93.6</td>
<td>71.3</td>
<td>77.1</td>
<td>99.4</td>
<td>72.0±1.1</td>
</tr>
<tr>
<td>Marker 1.10.1</td>
<td>83.8</td>
<td>66.8</td>
<td>72.9</td>
<td>33.5</td>
<td>86.6</td>
<td>80.0</td>
<td>85.7</td>
<td>99.3</td>
<td>76.1±1.1</td>
</tr>
<tr>
<td>MinerU 2.5.4*</td>
<td>76.6</td>
<td>54.6</td>
<td>84.9</td>
<td>33.7</td>
<td>96.6</td>
<td>78.2</td>
<td>83.5</td>
<td>93.7</td>
<td>75.2±1.1</td>
</tr>
<tr>
<td>DeepSeek-OCR</td>
<td>77.2</td>
<td>73.6</td>
<td>80.2</td>
<td>33.3</td>
<td>96.1</td>
<td>66.4</td>
<td>79.4</td>
<td>99.8</td>
<td>75.7±1.0</td>
</tr>
<tr>
<td>Nanonets-OCR2-3B</td>
<td>75.4</td>
<td>46.1</td>
<td>86.8</td>
<td>40.9</td>
<td>32.1</td>
<td>81.9</td>
<td>93.0</td>
<td>99.6</td>
<td>69.5±1.1</td>
</tr>
<tr>
<td>PaddleOCR-VL*</td>
<td>85.7</td>
<td>71.0</td>
<td>84.1</td>
<td>37.8</td>
<td>97.0</td>
<td>79.9</td>
<td>85.7</td>
<td>98.5</td>
<td>80.0±1.0</td>
</tr>
<tr>
<td>Infinity-Parser 7B*</td>
<td>84.4</td>
<td>83.8</td>
<td>85.0</td>
<td>47.9</td>
<td>88.7</td>
<td>84.2</td>
<td>86.4</td>
<td>99.8</td>
<td>82.5±?</td>
</tr>
<tr>
<td>Chandra OCR 0.1.0*</td>
<td>82.2</td>
<td>80.3</td>
<td>88.0</td>
<td>50.4</td>
<td>90.8</td>
<td>81.2</td>
<td>92.3</td>
<td>99.9</td>
<td>83.1±0.9</td>
</tr>
<tr>
<td colspan="10"><hr></td>
</tr>
<tr>
<td><strong>olmOCR v0.4.0</strong></td>
<td>83.0</td>
<td>82.3</td>
<td>84.9</td>
<td>47.7</td>
<td>96.1</td>
<td>83.7</td>
<td>81.9</td>
<td>99.7</td>
<td>82.4±1.1</td>
</tr>
</tbody>
</table>
### Installation
#### System Dependencies
You will need to install poppler-utils and additional fonts for rendering PDF images.
Install dependencies (Ubuntu/Debian):
```bash
sudo apt-get update
sudo apt-get install poppler-utils ttf-mscorefonts-installer msttcorefonts fonts-crosextra-caladea fonts-crosextra-carlito gsfonts lcdf-typetools
```
#### Python Installation
Set up a conda environment and install olmocr. The requirements for running olmOCR
are difficult to install in an existing python environment, so please do make a clean python environment to install into.
```bash
conda create -n olmocr python=3.11
conda activate olmocr
```
Choose the installation option that matches your use case:
**Option 1: Remote Inference (Lightweight)**
If you plan to use a remote vLLM server with the `--server` flag, install the base package:
```bash
pip install olmocr
```
This avoids installing heavy GPU dependencies like PyTorch (~2GB+).
**Option 2: Local GPU Inference**
Requirements:
- Recent NVIDIA GPU (tested on RTX 4090, L40S, A100, H100) with at least 12 GB of GPU RAM
- 30GB of free disk space
For running inference with your own GPU:
```bash
pip install olmocr[gpu] --extra-index-url https://download.pytorch.org/whl/cu128
# Recommended: Install flash infer for faster inference on GPU
pip install https://download.pytorch.org/whl/cu128/flashinfer/flashinfer_python-0.2.5%2Bcu128torch2.7-cp38-abi3-linux_x86_64.whl
```
**Option 3: Beaker Cluster Execution**
For submitting jobs to Beaker clusters with the `--beaker` flag:
```bash
pip install olmocr[beaker]
```
**Option 4: Benchmark Suite**
For running the olmOCR benchmark suite:
```bash
pip install olmocr[bench]
```
**Combined Installation**
You can combine multiple options:
```bash
# GPU + Beaker support
pip install olmocr[gpu,beaker] --extra-index-url https://download.pytorch.org/whl/cu128
# GPU + Benchmark support
pip install olmocr[gpu,bench] --extra-index-url https://download.pytorch.org/whl/cu128
```
**Troubleshooting**
If you run into errors about `too many open files`, update your ulimit:
```bash
ulimit -n 65536
```
### Usage Examples
For quick testing, try the [web demo](https://olmocr.allen.ai/).
**Convert a Single PDF (Local GPU):**
```bash
# Download a sample PDF
curl -o olmocr-sample.pdf https://olmocr.allenai.org/papers/olmocr_3pg_sample.pdf
# Convert it to markdown
olmocr ./localworkspace --markdown --pdfs olmocr-sample.pdf
```
**Convert an Image file:**
```bash
olmocr ./localworkspace --markdown --pdfs random_page.png
```
**Convert Multiple PDFs:**
```bash
olmocr ./localworkspace --markdown --pdfs tests/gnarly_pdfs/*.pdf
```
**Use Remote Inference Server:**
```bash
olmocr ./localworkspace --server http://remote-server:8000/v1 --model allenai/olmOCR-2-7B-1025-FP8 --markdown --pdfs *.pdf
```
With the `--markdown` flag, results will be stored as markdown files inside of `./localworkspace/markdown/`.
> **Note:** You can also use `python -m olmocr.pipeline` instead of `olmocr` if you prefer.
#### Viewing Results
The `./localworkspace/` workspace folder will then have both [Dolma](https://github.com/allenai/dolma) and markdown files (if using `--markdown`).
```bash
cat localworkspace/markdown/olmocr-sample.md
```
```
olmOCR: Unlocking Trillions of Tokens in PDFs with Vision Language Models
...
```
### Using an Inference Provider or External Server
If you have a vLLM server already running elsewhere (or any inference platform implementing the OpenAI API), you can point olmOCR to use it instead of spawning a local instance.
**Installation for Remote Inference:**
```bash
# Lightweight installation - no GPU dependencies needed
pip install olmocr
```
**Using an External Server:**
```bash
# Use external vLLM server instead of local one
olmocr ./localworkspace --server http://remote-server:8000/v1 --model allenai/olmOCR-2-7B-1025-FP8 --markdown --pdfs tests/gnarly_pdfs/*.pdf
```
The served model name in vLLM needs to match the value provided in `--model`.
**Example vLLM Server Launch:**
```bash
vllm serve allenai/olmOCR-2-7B-1025-FP8 --max-model-len 16384
```
#### Verified External Providers
We have tested `olmOCR-2-7B-1025-FP8` on these external model providers and confirmed that they work
| | $/1M Input tokens | $/1M Output tokens | Example Command |
|-----------------------------------------------------------------------------|-------------------|--------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [Cirrascale](https://ai2endpoints.cirrascale.ai/models/overview) | $0.07 | $0.15 | `olmocr ./workspace --server https://ai2endpoints.cirrascale.ai/api --api_key sk-XXXXXXX --workers 1 --max_concurrent_requests 20 --model olmOCR-2-7B-1025 --pdfs tests/gnarly_pdfs/*.pdf` |
| [DeepInfra](https://deepinfra.com/) | $0.09 | $0.19 | `olmocr ./workspace --server https://api.deepinfra.com/v1/openai --api_key DfXXXXXXX --workers 1 --max_concurrent_requests 20 --model allenai/olmOCR-2-7B-1025 --pdfs tests/gnarly_pdfs/*.pdf` |
| [Parasail](https://www.saas.parasail.io/serverless?name=olmocr-7b-1025-fp8) | $0.10 | $0.20 | `olmocr ./workspace --server https://api.parasail.io/v1 --api_key psk-XXXXX --workers 1 --max_concurrent_requests 20 --model allenai/olmOCR-2-7B-1025 --pdfs tests/gnarly_pdfs/*.pdf` |
Notes on arguments
- `--server`: Defines the OpenAI-compatible endpoint: ex `https://api.deepinfra.com/v1/openai`
- `--api_key`: Your API key, bassed in via Authorization Bearer HTTP header
- `--max_concurrent_requests`: Max concurrent requests that will be in-flight to the inference provider at one time
- `--workers`: Max number of page groups that will be processed at once. You may want to set this to `1` so that you finish one group of stuff before moving on.
- `--pages_per_group`: You may want a smaller number of pages per group as many external provides have lower concurrent request limits
- `--model`: The model identifier, ex. `allenai/olmOCR-2-7B-1025`, different providers have different names, and if you run locally, you can use `olmocr`
- Other arguments work the same as with local inference
### Multi-node / Cluster Usage
If you want to convert millions of PDFs using multiple nodes running in parallel, olmOCR supports
reading PDFs from AWS S3 and coordinating work using an AWS S3 output bucket.
**Start the first worker node:**
```bash
olmocr s3://my_s3_bucket/pdfworkspaces/exampleworkspace --pdfs s3://my_s3_bucket/jakep/gnarly_pdfs/*.pdf
```
This sets up a simple work queue in your AWS bucket and starts converting PDFs.
**On subsequent worker nodes:**
```bash
olmocr s3://my_s3_bucket/pdfworkspaces/exampleworkspace
```
They will automatically start grabbing items from the same workspace queue.
#### Using Beaker for Cluster Execution
If you are at Ai2 and want to linearize millions of PDFs efficiently using [beaker](https://www.beaker.org), install with Beaker support:
```bash
pip install olmocr[gpu,beaker] --extra-index-url https://download.pytorch.org/whl/cu128
```
Then use the `--beaker` flag to prepare the workspace locally and launch N GPU workers in the cluster:
```bash
olmocr s3://my_s3_bucket/pdfworkspaces/exampleworkspace --pdfs s3://my_s3_bucket/jakep/gnarly_pdfs/*.pdf --beaker --beaker_gpus 4
```
### Using Docker
Pull the Docker image (large, includes the model, ~30GB):
```bash
docker pull alleninstituteforai/olmocr:latest-with-model
```
For advanced users who want to manage their own model downloads, we also provide a base image without the model:
```bash
docker pull alleninstituteforai/olmocr:latest
```
#### Quick Start - Process PDFs
Process a single PDF in your current directory:
```bash
docker run --gpus all \
-v $(pwd):/workspace \
alleninstituteforai/olmocr:latest-with-model \
-c "olmocr /workspace/output --markdown --pdfs /workspace/sample.pdf"
```
Process multiple PDFs:
```bash
docker run --gpus all \
-v /path/to/pdfs:/input \
-v /path/to/output:/output \
alleninstituteforai/olmocr:latest-with-model \
-c "olmocr /output --markdown --pdfs /input/*.pdf"
```
#### Interactive Mode
Run the container interactively for exploration and debugging:
```bash
docker run -it --gpus all alleninstituteforai/olmocr:latest-with-model
```
> Visit our Docker repository on [Docker Hub](https://hub.docker.com/r/alleninstituteforai/olmocr) for more information.
### Full Documentation
To see all available options:
```bash
olmocr --help
usage: pipeline.py [-h] [--pdfs [PDFS ...]] [--model MODEL] [--workspace_profile WORKSPACE_PROFILE] [--pdf_profile PDF_PROFILE] [--pages_per_group PAGES_PER_GROUP] [--max_page_retries MAX_PAGE_RETRIES] [--max_page_error_rate MAX_PAGE_ERROR_RATE] [--workers WORKERS]
[--apply_filter] [--stats] [--markdown] [--target_longest_image_dim TARGET_LONGEST_IMAGE_DIM] [--target_anchor_text_len TARGET_ANCHOR_TEXT_LEN] [--guided_decoding] [--gpu-memory-utilization GPU_MEMORY_UTILIZATION] [--max_model_len MAX_MODEL_LEN]
[--tensor-parallel-size TENSOR_PARALLEL_SIZE] [--data-parallel-size DATA_PARALLEL_SIZE] [--port PORT] [--server SERVER] [--beaker] [--beaker_workspace BEAKER_WORKSPACE] [--beaker_cluster BEAKER_CLUSTER] [--beaker_gpus BEAKER_GPUS] [--beaker_priority BEAKER_PRIORITY]
workspace
Manager for running millions of PDFs through a batch inference pipeline
positional arguments:
workspace The filesystem path where work will be stored, can be a local folder, or an s3 path if coordinating work with many workers, s3://bucket/prefix/
options:
-h, --help show this help message and exit
--pdfs [PDFS ...] Path to add pdfs stored in s3 to the workspace, can be a glob path s3://bucket/prefix/*.pdf or path to file containing list of pdf paths
--model MODEL Path where the model is located, allenai/olmOCR-7B-0725-FP8 is the default, can be local, s3, or hugging face.
--workspace_profile WORKSPACE_PROFILE
S3 configuration profile for accessing the workspace
--pdf_profile PDF_PROFILE
S3 configuration profile for accessing the raw pdf documents
--pages_per_group PAGES_PER_GROUP
Aiming for this many pdf pages per work item group
--max_page_retries MAX_PAGE_RETRIES
Max number of times we will retry rendering a page
--max_page_error_rate MAX_PAGE_ERROR_RATE
Rate of allowable failed pages in a document, 1/250 by default
--workers WORKERS Number of workers to run at a time
--apply_filter Apply basic filtering to English pdfs which are not forms, and not likely seo spam
--stats Instead of running any job, reports some statistics about the current workspace
--markdown Also write natural text to markdown files preserving the folder structure of the input pdfs
--target_longest_image_dim TARGET_LONGEST_IMAGE_DIM
Dimension on longest side to use for rendering the pdf pages
--target_anchor_text_len TARGET_ANCHOR_TEXT_LEN
Maximum amount of anchor text to use (characters), not used for new models
--guided_decoding Enable guided decoding for model YAML type outputs
VLLM arguments:
--gpu-memory-utilization GPU_MEMORY_UTILIZATION
Fraction of VRAM vLLM may pre-allocate for KV-cache (passed through to vllm serve).
--max_model_len MAX_MODEL_LEN
Upper bound (tokens) vLLM will allocate KV-cache for, lower if VLLM won't start
--tensor-parallel-size TENSOR_PARALLEL_SIZE, -tp TENSOR_PARALLEL_SIZE
Tensor parallel size for vLLM
--data-parallel-size DATA_PARALLEL_SIZE, -dp DATA_PARALLEL_SIZE
Data parallel size for vLLM
--port PORT Port to use for the VLLM server
--server SERVER URL of external vLLM (or other compatible provider)
server (e.g., http://hostname:port). If provided,
skips spawning local vLLM instance
beaker/cluster execution:
--beaker Submit this job to beaker instead of running locally
--beaker_workspace BEAKER_WORKSPACE
Beaker workspace to submit to
--beaker_cluster BEAKER_CLUSTER
Beaker clusters you want to run on
--beaker_gpus BEAKER_GPUS
Number of gpu replicas to run
--beaker_priority BEAKER_PRIORITY
Beaker priority level for the job
```
## Code overview
There are some nice reusable pieces of the code that may be useful for your own projects:
- A prompting strategy to get really good natural text parsing using ChatGPT 4o - [buildsilver.py](https://github.com/allenai/olmocr/blob/main/olmocr/data/buildsilver.py)
- Basic filtering by language and SEO spam removal - [filter.py](https://github.com/allenai/olmocr/blob/main/olmocr/filter/filter.py)
- SFT Finetuning code for Qwen2.5-VL - [train.py](https://github.com/allenai/olmocr/blob/main/olmocr/train/train.py)
- GRPO RL Trainer - [grpo_train.py](https://github.com/allenai/olmocr/blob/main/olmocr/train/grpo_train.py)
- Synthetic data generation - [mine_html_templates.py](https://github.com/allenai/olmocr/blob/main/olmocr/synth/mine_html_templates.py)
- Processing millions of PDFs through a finetuned model using VLLM - [pipeline.py](https://github.com/allenai/olmocr/blob/main/olmocr/pipeline.py)
- Viewing [Dolma docs](https://github.com/allenai/dolma) created from PDFs - [dolmaviewer.py](https://github.com/allenai/olmocr/blob/main/olmocr/viewer/dolmaviewer.py)
## Team
<!-- start team -->
**olmOCR** is developed and maintained by the AllenNLP team, backed by [the Allen Institute for Artificial Intelligence (AI2)](https://allenai.org/).
AI2 is a non-profit institute with the mission to contribute to humanity through high-impact AI research and engineering.
To learn more about who specifically contributed to this codebase, see [our contributors](https://github.com/allenai/olmocr/graphs/contributors) page.
<!-- end team -->
## License
<!-- start license -->
**olmOCR** is licensed under [Apache 2.0](https://www.apache.org/licenses/LICENSE-2.0).
A full copy of the license can be found [on GitHub](https://github.com/allenai/olmocr/blob/main/LICENSE).
<!-- end license -->
## Citing
For olmOCR v1 and OlmOCR-bench:
```bibtex
@misc{olmocrbench,
title={{olmOCR: Unlocking Trillions of Tokens in PDFs with Vision Language Models}},
author={Jake Poznanski and Jon Borchardt and Jason Dunkelberger and Regan Huff and Daniel Lin and Aman Rangapur and Christopher Wilhelm and Kyle Lo and Luca Soldaini},
year={2025},
eprint={2502.18443},
archivePrefix={arXiv},
primaryClass={cs.CL},
url={https://arxiv.org/abs/2502.18443},
}
```
For olmOCR v2 Unit Testing Rewards with RL:
```bibtex
@misc{olmocr2,
title={olmOCR 2: Unit Test Rewards for Document OCR},
author={Jake Poznanski and Luca Soldaini and Kyle Lo},
year={2025},
eprint={2510.19817},
archivePrefix={arXiv},
primaryClass={cs.CV},
url={https://arxiv.org/abs/2510.19817},
}
```
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`allenai/olmocr`
- 原始仓库:https://github.com/allenai/olmocr
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+24
View File
@@ -0,0 +1,24 @@
# GitHub Release Process
## Steps
1. Update the version in `olmocr/version.py`.
3. Run the release script:
```bash
./scripts/release.sh
```
This will commit the changes to the CHANGELOG and `version.py` files and then create a new tag in git
which will trigger a workflow on GitHub Actions that handles the rest.
## Fixing a failed release
If for some reason the GitHub Actions release workflow failed with an error that needs to be fixed, you'll have to delete both the tag and corresponding release from GitHub. After you've pushed a fix, delete the tag from your local clone with
```bash
git tag -l | xargs git tag -d && git fetch -t
```
Then repeat the steps above.
+1
View File
@@ -0,0 +1 @@
build
+20
View File
@@ -0,0 +1,20 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?= -W
SPHINXBUILD ?= sphinx-build
SOURCEDIR = source
BUILDDIR = build
# Put it first so that "make" without argument is like "make help".
help:
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
.PHONY: help Makefile
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+35
View File
@@ -0,0 +1,35 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build
)
set SOURCEDIR=source
set BUILDDIR=build
if "%1" == "" goto help
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.If you don't have Sphinx installed, grab it from
echo.https://www.sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:help
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd
+1
View File
@@ -0,0 +1 @@
../../CHANGELOG.md
+1
View File
@@ -0,0 +1 @@
../../.github/CONTRIBUTING.md
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+121
View File
@@ -0,0 +1,121 @@
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import logging
import os
import sys
from datetime import datetime
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
sys.path.insert(0, os.path.abspath("../../"))
from olmocr import VERSION, VERSION_SHORT # noqa: E402
# -- Project information -----------------------------------------------------
project = "olmocr"
copyright = f"{datetime.today().year}, Allen Institute for Artificial Intelligence"
author = "Allen Institute for Artificial Intelligence"
version = VERSION_SHORT
release = VERSION
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.napoleon",
"myst_parser",
"sphinx.ext.intersphinx",
"sphinx.ext.viewcode",
"sphinx.ext.doctest",
"sphinx_copybutton",
"sphinx_autodoc_typehints",
]
# Tell myst-parser to assign header anchors for h1-h3.
myst_heading_anchors = 3
suppress_warnings = ["myst.header"]
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build"]
source_suffix = [".rst", ".md"]
intersphinx_mapping = {
"python": ("https://docs.python.org/3", None),
# Uncomment these if you use them in your codebase:
# "torch": ("https://pytorch.org/docs/stable", None),
# "datasets": ("https://huggingface.co/docs/datasets/master/en", None),
# "transformers": ("https://huggingface.co/docs/transformers/master/en", None),
}
# By default, sort documented members by type within classes and modules.
autodoc_member_order = "groupwise"
# Include default values when documenting parameter types.
typehints_defaults = "comma"
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = "furo"
html_title = f"olmocr v{VERSION}"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ["_static"]
html_css_files = ["css/custom.css"]
html_favicon = "_static/favicon.ico"
html_theme_options = {
"footer_icons": [
{
"name": "GitHub",
"url": "https://github.com/allenai/olmocr",
"html": """
<svg stroke="currentColor" fill="currentColor" stroke-width="0" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"></path>
</svg>
""", # noqa: E501
"class": "",
},
],
}
# -- Hack to get rid of stupid warnings from sphinx_autodoc_typehints --------
class ShutupSphinxAutodocTypehintsFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
if "Cannot resolve forward reference" in record.msg:
return False
return True
logging.getLogger("sphinx.sphinx_autodoc_typehints").addFilter(ShutupSphinxAutodocTypehintsFilter())
+27
View File
@@ -0,0 +1,27 @@
# **olmocr**
```{toctree}
:maxdepth: 2
:hidden:
:caption: Getting started
installation
overview
```
```{toctree}
:hidden:
:caption: Development
CHANGELOG
CONTRIBUTING
License <https://raw.githubusercontent.com/allenai/olmocr/main/LICENSE>
GitHub Repository <https://github.com/allenai/olmocr>
```
## Indices and tables
```{eval-rst}
* :ref:`genindex`
* :ref:`modindex`
```
+27
View File
@@ -0,0 +1,27 @@
Installation
============
**olmocr** supports Python >= 3.8.
## Installing with `pip`
**olmocr** is available [on PyPI](https://pypi.org/project/olmocr/). Just run
```bash
pip install olmocr
```
## Installing from source
To install **olmocr** from source, first clone [the repository](https://github.com/allenai/olmocr):
```bash
git clone https://github.com/allenai/olmocr.git
cd olmocr
```
Then run
```bash
pip install -e .
```
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

+3
View File
@@ -0,0 +1,3 @@
Overview
========
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
from .version import VERSION, VERSION_SHORT
+322
View File
@@ -0,0 +1,322 @@
# olmOCR-Bench
Dataset Link: https://huggingface.co/datasets/allenai/olmOCR-bench
We develop olmOCR-Bench in order to automatically and effectively evaluate document-level OCR of various tools.
olmOCR-Bench works by testing various "facts" about document pages at the PDF-level.
Our intention is that each "fact" is very simple, unambiguous, and machine-checkable, similar to a unit test. For example, once your document has been OCRed, we may check that a particular sentence appears exactly somewhere on the page.
We stay away from soft metrics like edit distance comparisons, because they may assign lower scores for parses of the document that differ from the reference, but may in fact still be correct. For example, on a document containing multiple distinct articles: you want the text of each article to be grouped together, but the relative order of the two articles may not be critical. Also, some documents may have critical details, like switching x and y in an equation that can make all the difference in understanding, but would appear as just a single character edit in an edit-distance metric.
olmOCR-bench operates on single page PDFs directly. We make this choice because PDFs do preserve some digital metadata and information which may be helpful to some OCR systems. Almost any other format can be converted to a PDF, but not the reverse, so we try to preserve these original documents where possible.
We have run the benchmark against some contemporary OCR pipelines, but it is really easy
to run it against your own OCR tools. Your tool just needs to support Markdown or plain text output.
<div align="center">
<img src="https://github.com/allenai/olmocr/blob/main/scripts/plots/ocr_pareto.png?raw=true" width=800/>
</div>
## Results
<table>
<thead>
<tr>
<th></th>
<th>ArXiv</th>
<th>Old<br>scans<br>math</th>
<th>Tables</th>
<th>Old<br>scans</th>
<th>Headers<br>&<br>footers</th>
<th>Multi<br>column</th>
<th>Long<br>tiny<br>text</th>
<th>Base</th>
<th>Overall</th>
</tr>
</thead>
<tbody>
<tr>
<td>Mistral OCR API</td>
<td>77.2</td>
<td>67.5</td>
<td>60.6</td>
<td>29.3</td>
<td>93.6</td>
<td>71.3</td>
<td>77.1</td>
<td>99.4</td>
<td>72.0±1.1</td>
</tr>
<tr>
<td>Marker 1.10.1</td>
<td>83.8</td>
<td>66.8</td>
<td>72.9</td>
<td>33.5</td>
<td>86.6</td>
<td>80.0</td>
<td>85.7</td>
<td>99.3</td>
<td>76.1±1.1</td>
</tr>
<tr>
<td>MinerU 2.5.4*</td>
<td>76.6</td>
<td>54.6</td>
<td>84.9</td>
<td>33.7</td>
<td>96.6</td>
<td>78.2</td>
<td>83.5</td>
<td>93.7</td>
<td>75.2±1.1</td>
</tr>
<tr>
<td>DeepSeek-OCR</td>
<td>77.2</td>
<td>73.6</td>
<td>80.2</td>
<td>33.3</td>
<td>96.1</td>
<td>66.4</td>
<td>79.4</td>
<td>99.8</td>
<td>75.7±1.0</td>
</tr>
<tr>
<td>Nanonets-OCR2-3B</td>
<td>75.4</td>
<td>46.1</td>
<td>86.8</td>
<td>40.9</td>
<td>32.1</td>
<td>81.9</td>
<td>93.0</td>
<td>99.6</td>
<td>69.5±1.1</td>
</tr>
<tr>
<td>PaddleOCR-VL*</td>
<td>85.7</td>
<td>71.0</td>
<td>84.1</td>
<td>37.8</td>
<td>97.0</td>
<td>79.9</td>
<td>85.7</td>
<td>98.5</td>
<td>80.0±1.0</td>
</tr>
<tr>
<td>Infinity-Parser 7B*</td>
<td>84.4</td>
<td>83.8</td>
<td>85.0</td>
<td>47.9</td>
<td>88.7</td>
<td>84.2</td>
<td>86.4</td>
<td>99.8</td>
<td>82.5±?</td>
</tr>
<tr>
<td>Chandra OCR 0.1.0*</td>
<td>82.2</td>
<td>80.3</td>
<td>88.0</td>
<td>50.4</td>
<td>90.8</td>
<td>81.2</td>
<td>92.3</td>
<td>99.9</td>
<td>83.1±0.9</td>
</tr>
<tr>
<td colspan="10"><hr></td>
</tr>
<tr>
<td>olmOCR (first release)</td>
<td>63.3</td>
<td>67.5</td>
<td>62.3</td>
<td>38.6</td>
<td>93.4</td>
<td>67.6</td>
<td>54.8</td>
<td>97.9</td>
<td>68.2±1.1</td>
</tr>
<tr>
<td>v0.1.60 + Dynamic temp scaling</td>
<td>71.4</td>
<td>73.1</td>
<td>65.6</td>
<td>40.5</td>
<td>93.2</td>
<td>76.6</td>
<td>64.9</td>
<td>96.7</td>
<td>72.8±1.2</td>
</tr>
<tr>
<td>v0.1.68 + Better prompting</td>
<td>76.3</td>
<td>76.0</td>
<td>70.2</td>
<td>43.2</td>
<td>94.1</td>
<td>77.5</td>
<td>71.9</td>
<td>96.8</td>
<td>75.8±1.0</td>
</tr>
<tr>
<td>v0.2.0 + New trainer, YAML, img resize, Qwen 2.5 VL</td>
<td>78.8</td>
<td>77.5</td>
<td>71.9</td>
<td>45.4</td>
<td>94.2</td>
<td>78.6</td>
<td>81.4</td>
<td>99.8</td>
<td>78.5±1.1</td>
</tr>
<tr>
<td>v0.3.0 + Handle blank pages</td>
<td>78.6</td>
<td>79.9</td>
<td>72.9</td>
<td>43.9</td>
<td>95.1</td>
<td>77.3</td>
<td>81.2</td>
<td>98.9</td>
<td>78.5±1.1</td>
</tr>
<tr>
<td>v0.4.0 + Synth data, RLVR, souping</td>
<td>83.0</td>
<td>82.3</td>
<td>84.9</td>
<td>47.7</td>
<td>96.1</td>
<td>83.7</td>
<td>81.9</td>
<td>99.7</td>
<td>82.4±1.1</td>
</tr>
</tbody>
</table>
<sup><sub>Results are reproduced in-house, except those marked with *, which are reported by model authors.
</sub></sup>
## Sourcing Documents and Tests
We define 7 distinct document types that we found olmOCR (or its earlier iterations) often struggled to process and defined custom acquisition strategies for each (described below). We removed documents that both contained PII and were not meant for public dissemination. We also decontaminate against documents that appear in olmOCR-Mix via URL level deduplication. To scale creation of test cases over these documents, we combined manual design and review with prompting GPT-4o.
### Document Types
- **arXiv Math (AR)**: We downloaded a recent set of papers from the math subset of arXiv, selecting manuscripts with a single TeX source file and corresponding rendered PDF. To select a candidate LATEX expression from a page to use in a test, we (1) ran olmOCR to identify candidate pages with TeX, (2) match pages back to original TeX source, and (3) validate matched TeX rendering compatibility with KaTeX. We manually verify the final set of test cases to exclude instances where custom macros produce renderings that deviate from standard LATEX and to split multi-part equations into smaller test cases.
- **Old Scans Math (OSM)**: We crawl old, public domain math textbooks from the Internet Archive, extracting random pages from these documents. We similarly use olmOCR to find candidate pages with formulas, but this time manually annotate each formula on the page to use as test cases.
- **Tables (TA)**: We sampled more documents from the same internal crawled PDF repository used to create olmOCR-Mix and filtered to those which had tables using a simple prompt with Gemini-Flash-2.0. On pages with tables, we prompted Gemini-Flash-2.0 for the relationships between randomly chosen cells. We manually reviewed those tests for accuracy.
- **Old Scans (OS)**: We sampled historical letters and typewritten documents with existing human transcriptions from the Library of Congress digital archives. We then wrote a small script to generate Natural Reading Order cases consisting of sentences that were naturally before or after one another in the original human transcriptions. We manually added test cases to cover some headers/footers which should have been excluded from any OCR version of these documents. All of the test cases then underwent a second pass of human review for accuracy.
- **Headers Footers (HF)**: We sampled documents from the same internally crawled PDF repository as olmOCR-Mix. We used DocLayout-YOLO to identify page regions labeled as headers or footers using the abandon category. To extract the text from these header/footer regions, we visually mask out the rest of the document and prompt Gemini-Flash-2.0 for the content. These extracted snippets are added as test cases that should be absent in linearized output. We manually reviewed to remove mistakenly filtered text and to set conditions such as limiting the search area to the first N or last N characters.
- **Multi Column (MC)**: We visually sample documents from our internal crawled PDF repository to find documents with multi-column layouts and multiple articles on one page. We use Claude-Sonnet-3.7 to render those pages to HTML, and from that HTML, we extract text segments before/after one another. We manually review each entry for accuracy. We purposely select simple text blocks from coherent regions of the document, and avoid including any math formulas, superscripts, or subscripts in these tests.
- **Long Tiny Text (LTT)**: We crawled documents from the Internet Archive containing a large amount of dense, small print on a single page. Such documents include pages from a dictionary or pages of references from academic papers. We then generate test cases using Gemini-Flash-2.0 and verify them manually.
## Benchmark Principles
As we created olmOCR-bench, we also kept a few general rules in mind:
- We expect your OCR system to output a plain-text Unicode document in a reading order that would be considered natural.
- Documents from the benchmark should fit on a standard A4 piece of paper and still be readable to a human.
- Markdown syntax is allowed, but ignored. Ex. if we are looking for the word "enlightenment" to appear on a page, and your system outputs "**\*\*enlightenment\*\***" in Markdown bold, that still counts.
- olmOCR-bench is not position sensitive, ex. we check that a sentence or math equation appears anywhere on a page. The exception to this is header/footer tests where we want to find simple page numbers appearing in the first or last few characters of a page.
- Tables can be in either Markdown syntax, or as an html `<table>`.
- Math equations must render with [Katex](https://katex.org/) and be delimeted with $, $$, \\(, or \\[.
- Math equations are not position sensitive either, so if we are checking for
$ 3x^2 $ to appear on a page, then outputting $ \int_a^b{ 3x ^ 2dx} $ counts.
- We normalize all Unicode to NFC before running the benchmark, so if your OCR model outputs é vs e + ◌́ then either way should not affect your benchmark score.
- We normalize all the different variants of hyphens to the ascii -, all the variants of double quoets to ascii " and all variants of single quotes/apostrophes to ascii '. You should score the same on the benchmark if you output - vs —
- All facts checked about documents are either pass/fail. We want it to be very clear if your OCR system fails a test, and if so, what output would make it pass.
## olmOCR-Bench Test classes
- Text presence
- This task makes sure that a given small piece of text (ex. 1-3 sentence level) is present within
a parsed document. Soft/fuzzy matching is allowed, as well as specifying if the text must be in the first N or last N characters of the document. Case sensitive by default.
- Text absense
- This task makes sure that a given piece of next does NOT appear in the OCR'ed version of a document. We generally want our OCR systems to filter out content like headers/footers/page numbers from documents. The same fuzzy matching as in Text Presence tests is allowed.
- Natural Reading Order
- This task ensures that blocks of text which are present have a defined order relative to one another. For example,
on a document that contains multiple news articles on one page, you'd want to see that the first sentence of the
first article appears after the heading of that article. But, you may be okay with swapping the order of those
two articles.
- Table Accuracy
- Both Markdown and HTML based tables are supported. These tests check that a cell with a given text exists somewhere in the table, and that its neighbors have certain properties. Ex. A cell exists on this page with text "4.5%" and above that is a cell with the text "2.4%". However, it's important to note that some tests depend on rowspan and colspan information being present in the table, which is only available with HTML based tables. This means that a model outputting only markdown tables cannot achieve a max score on this section.
- Math Formula Accuracy
- We render a given Latex style equation using Katex in a headless browser. And then see if it exists anywhere in the final OCRed document. Matching is performed on a relative symbol level, ex. in "\f\relax{x} = \int_{-\infty}^\infty
x^2dx" we check that a ∫ appears to the left of a x, x appears to the left of dx, etc...
## Downloading and running the benchmark
Currently the full benchmark data is located here:
https://huggingface.co/datasets/allenai/olmOCR-bench
To run a benchmark, first install the bench requirements
```bash
conda create -n olmocr python=3.11
conda activate olmocr
git clone https://github.com/allenai/olmocr.git
cd olmocr
# Install olmocr and the requirements needed to run the benchmark
pip install -e .[bench]
# Configure playwright headless browser to run the math rendering tests
playwright install chromium
# Now clone the benchmark data from hugging face, this includes the PDFs and JSON annotation data
huggingface-cli download --repo-type dataset --resume-download allenai/olmOCR-bench --local-dir ./olmOCR-bench
```
Convert your documents
```bash
# You will need to install the [gpu] subset of olmocr dependencies to run gpu inference
# Then convert using using olmocr.bench.convert, see the olmocr/bench/runners directory for options
pip install olmocr[gpu] --find-links https://flashinfer.ai/whl/cu124/torch2.4/flashinfer/
python -m olmocr.bench.convert olmocr_pipeline --dir ./olmOCR-bench/bench_data
# OR, you can use the pipeline to convert the benchmark PDFs and move them into the final format
python -m olmocr.pipeline ./localworkspace --markdown --pdfs ./olmOCR-bench/bench_data/pdfs/**/*.pdf
python olmocr/bench/scripts/workspace_to_bench.py localworkspace/ olmOCR-bench/bench_data/olmocr --bench-path ./olmOCR-bench/
```
Now run the benchmark
```bash
python -m olmocr.bench.benchmark --dir ./olmOCR-bench/bench_data
```
## Previewing the benchmark questions
We have an internal data annotation tool that can be used to review the questions in the benchmark, and make edits.
<img width="700" alt="image" src="https://github.com/user-attachments/assets/dd24fd88-a642-4379-b5a1-9911717bf5b1" />
```bash
python -m olmocr.bench.review_app --port 5000 --debug ./olmOCR-bench/bench_data/multi_column.jsonl --force
```
View File
+463
View File
@@ -0,0 +1,463 @@
#!/usr/bin/env python3
"""
This script runs olmocr bench.
It will take as an argument a folder, and scan it for .jsonl files which contain the various rules and properties that we will check.
It will then validate the JSON files to make sure they are all valid.
Then, each other folder in there (besides /pdfs) represents a pipeline tool that we will evaluate.
We will validate that each one of those contains at least one .md file (or repeated generations, e.g. _pg{page}_repeat{repeat}.md)
corresponding to its parse for every .pdf in the /pdfs folder.
Then, we will read each one, and check if they pass against all the rules.
If a rule fails on some of the repeats, a short explanation is printed.
The final score is the average of per-JSONL file scores, where each JSONL file's score is the proportion of tests from that file that pass.
Statistical analysis including bootstrap confidence intervals are provided for the results.
Pairwise permutation tests are conducted between specific candidate pairs.
"""
import argparse
import glob
import os
import random
import re
import sys
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Dict, List, Tuple
from pypdf import PdfReader
from tqdm import tqdm
from .report import generate_html_report
from .tests import BaselineTest, BasePDFTest, load_tests, save_tests
from .utils import calculate_bootstrap_ci
def evaluate_candidate(
candidate_folder: str, all_tests: List[BasePDFTest], pdf_basenames: List[str], force: bool = False
) -> Tuple[float, int, List[str], List[str], Dict[str, List[float]], List[float], Dict[str, Dict[int, List[Tuple[BasePDFTest, bool, str]]]]]:
"""
For the candidate folder (pipeline tool output), validate that it contains at least one .md file
(i.e. repeated generations like _pg{page}_repeat{repeat}.md) for every PDF in the pdf folder.
Then, run each rule against all corresponding .md files concurrently and average the results.
Returns a tuple:
(overall_score, total_tests, candidate_errors, test_failures, test_type_breakdown, all_test_scores, test_results)
- overall_score: Average fraction of tests passed (averaged over repeats and tests).
Note: This is now updated at reporting time to be the average of per-JSONL file scores.
- total_tests: Total number of tests evaluated.
- candidate_errors: List of candidate errors (e.g. missing files).
- test_failures: List of failure messages for tests not passing on all repeats.
- test_type_breakdown: Dictionary mapping test type to list of average pass ratios for tests of that type.
- all_test_scores: List of all individual test scores (used for bootstrapping).
- test_results: Dictionary mapping PDF name to dictionary mapping page number to list of (test, passed, explanation) tuples.
"""
candidate_errors = []
test_failures = []
test_type_breakdown = {} # key: test type, value: list of average pass ratios
all_test_scores = [] # Store all individual test scores for bootstrapping
test_results = {} # Store detailed test results for reporting
candidate_name = os.path.basename(candidate_folder)
# Map each PDF to its corresponding MD repeats (e.g., doc1_pg1_repeat1.md, doc1_pg2_repeat2.md, etc.)
pdf_to_md_files = {}
all_files = list(glob.glob(os.path.join(candidate_folder, "**/*.md"), recursive=True))
for pdf_name in pdf_basenames:
md_base = os.path.splitext(pdf_name)[0]
md_regex = re.compile(rf"^{re.escape(md_base)}_pg\d+_repeat\d+\.md$")
md_files = [f for f in all_files if md_regex.match(os.path.relpath(f, candidate_folder))]
if not md_files and not force:
candidate_errors.append(
f"Candidate '{candidate_name}' is missing MD repeats for {pdf_name} " f"(expected files matching {md_base}_pg{{page}}_repeat*.md)."
)
else:
pdf_to_md_files[pdf_name] = md_files
if candidate_errors:
return (0.0, len(all_tests), candidate_errors, test_failures, test_type_breakdown, all_test_scores, test_results)
# Define an inner function to evaluate a single test
def process_test(test: BasePDFTest) -> Tuple[float, str, str, List[str], Tuple[bool, str]]:
local_errors = []
test_failure = None
pdf_name = test.pdf
# Initialize the test_results structure if needed
if pdf_name not in test_results:
test_results[pdf_name] = {}
if test.page not in test_results[pdf_name]:
test_results[pdf_name][test.page] = []
md_base = os.path.splitext(pdf_name)[0]
md_files = pdf_to_md_files.get(pdf_name, [])
# Filter MD files for the specific page corresponding to the test
page_md_files = [f for f in md_files if re.search(rf"_pg{test.page}_", os.path.basename(f))]
if not page_md_files:
local_errors.append(
f"Candidate '{candidate_name}' is missing MD repeats for {pdf_name} page {test.page} "
f"(expected files matching {md_base}_pg{test.page}_repeat*.md)."
)
test_results[pdf_name][test.page].append((test, False, "Missing MD files"))
return (0.0, None, test.type, local_errors, (False, "Missing MD files"))
repeat_passes = 0
num_repeats = 0
explanations = []
for md_path in page_md_files:
num_repeats += 1
try:
with open(md_path, "r", encoding="utf-8") as f:
md_content = f.read()
except Exception as e:
local_errors.append(f"Error reading {md_path}: {e}")
continue
try:
passed, explanation = test.run(md_content)
if passed:
repeat_passes += 1
else:
explanations.append(explanation)
except Exception as e:
local_errors.append(f"Error running test {test.id} on {md_path}: {e}")
explanations.append(str(e))
test_avg = repeat_passes / num_repeats if num_repeats > 0 else 0.0
final_passed = test_avg > 0.5 # Consider test passed if majority of repeats pass
final_explanation = explanations[0] if explanations else "All repeats passed"
# Store the test result for reporting
test_results[pdf_name][test.page].append((test, final_passed, final_explanation))
if test_avg < 1.0:
test_failure = (
f"Test {test.id} on {md_base} page {test.page} average pass ratio: {test_avg:.3f} "
f"({repeat_passes}/{num_repeats} repeats passed). Ex: {explanations[0] if explanations else 'No explanation'}"
)
return (test_avg, test_failure, test.type, local_errors, (final_passed, final_explanation))
total_test_score = 0.0
futures = []
# Use a thread pool to evaluate each test concurrently.
with ThreadPoolExecutor(max_workers=min(os.cpu_count() or 1, 64)) as executor:
futures = [executor.submit(process_test, test) for test in all_tests]
# tqdm progress bar for this candidate's tests
for future in tqdm(as_completed(futures), total=len(futures), desc=f"Evaluating tests for {candidate_name}", unit="test"):
test_avg, test_failure, test_type, errors, _ = future.result()
all_test_scores.append(test_avg)
total_test_score += test_avg
if test_failure:
test_failures.append(test_failure)
if test_type not in test_type_breakdown:
test_type_breakdown[test_type] = []
test_type_breakdown[test_type].append(test_avg)
local_errors = errors
if local_errors:
candidate_errors.extend(local_errors)
overall_score = total_test_score / len(all_tests) if all_tests else 0.0
return (overall_score, len(all_tests), candidate_errors, test_failures, test_type_breakdown, all_test_scores, test_results)
def main():
parser = argparse.ArgumentParser(description="Run OLMOCR Bench.")
parser.add_argument(
"--dir",
default=os.path.join(os.path.dirname(__file__), "sample_data"),
help="Path to the folder containing .jsonl files, /pdfs folder, and pipeline tool subfolders.",
)
parser.add_argument(
"--force",
action="store_true",
help="Run benchmark even if some files are missing",
)
parser.add_argument("--candidate", type=str, default=None, help="Run test only for a single candidate")
parser.add_argument("--skip_baseline", action="store_true", help="Skip running baseline tests (ex. that check that basic content is present on each page)")
parser.add_argument(
"--bootstrap_samples",
type=int,
default=1000,
help="Number of bootstrap samples for confidence interval calculation (default: 1000).",
)
parser.add_argument(
"--confidence_level",
type=float,
default=0.95,
help="Confidence level for interval calculation (default: 0.95 for 95% CI).",
)
# New arguments
parser.add_argument("--sample", type=int, default=None, help="Randomly sample N tests to run instead of all tests.")
parser.add_argument("--test_report", type=str, default=None, help="Generate an HTML report of test results. Provide a filename (e.g., results.html).")
parser.add_argument(
"--output_failed", type=str, default=None, help="Output a JSONL file containing tests that failed across all candidates. Provide a filename."
)
parser.add_argument("--max_reports", type=int, default=None, help="Limit the HTML report to at most N unique PDFs per .jsonl file.")
args = parser.parse_args()
input_folder = args.dir if os.path.isdir(args.dir) else os.path.dirname(args.dir)
n_bootstrap = args.bootstrap_samples
ci_level = args.confidence_level
pdf_folder = os.path.join(input_folder, "pdfs")
if not os.path.exists(pdf_folder):
print("Error: /pdfs folder must exist in your data directory.", file=sys.stderr)
sys.exit(1)
all_pdf_files = list(glob.glob(os.path.join(pdf_folder, "**/*.pdf"), recursive=True))
if not all_pdf_files:
print(f"Error: No PDF files found in {pdf_folder}", file=sys.stderr)
sys.exit(1)
pdf_basenames = [os.path.relpath(p, pdf_folder) for p in all_pdf_files]
if os.path.isfile(args.dir):
jsonl_files = [args.dir]
else:
jsonl_files = glob.glob(os.path.join(input_folder, "*.jsonl"))
if not jsonl_files:
print(f"Error: No .jsonl files found in {input_folder}.", file=sys.stderr)
sys.exit(1)
all_tests = []
test_to_jsonl = {} # Map test IDs to their source jsonl files
for jsonl_path in jsonl_files:
jsonl_basename = os.path.basename(jsonl_path)
tests = load_tests(jsonl_path)
for test in tests:
test_to_jsonl[test.id] = jsonl_basename
all_tests.extend(tests)
if not all_tests:
print("No valid tests found. Exiting.", file=sys.stderr)
sys.exit(1)
# When a single .jsonl file is passed, only consider PDFs referenced by its tests
if os.path.isfile(args.dir):
referenced_pdfs = {t.pdf for t in all_tests}
pdf_basenames = [p for p in pdf_basenames if p in referenced_pdfs]
for pdf in pdf_basenames:
if not any(t.type == "baseline" for t in all_tests if t.pdf == pdf):
all_tests.append(BaselineTest(id=f"{pdf}_baseline", pdf=pdf, page=1, type="baseline"))
test_to_jsonl[all_tests[-1].id] = "baseline"
for pdf in pdf_basenames:
pdf_doc = PdfReader(os.path.join(pdf_folder, pdf))
for page in range(1, len(pdf_doc.pages) + 1):
if not any(test for test in all_tests if test.pdf == pdf and test.page == page) and not args.force:
print(f"No dataset entry found for pdf {pdf} page {page}")
sys.exit(1)
if args.skip_baseline:
all_tests = [test for test in all_tests if test.type != "baseline"]
# Sample tests if requested
if args.sample is not None and args.sample > 0:
if args.sample >= len(all_tests):
print(f"Sample size {args.sample} is greater than or equal to the total number of tests ({len(all_tests)}). Using all tests.")
else:
print(f"Randomly sampling {args.sample} tests out of {len(all_tests)} total tests.")
all_tests = random.sample(all_tests, args.sample)
candidate_folders = []
for entry in os.listdir(input_folder):
full_path = os.path.join(input_folder, entry)
if args.candidate is not None:
if entry == args.candidate:
candidate_folders.append(full_path)
else:
if os.path.isdir(full_path) and entry != "pdfs":
candidate_folders.append(full_path)
if not candidate_folders:
print("Error: No candidate pipeline folders found (subdirectories besides 'pdfs').", file=sys.stderr)
sys.exit(1)
candidate_folders.sort()
summary = []
test_results_by_candidate = {}
print("\nRunning tests for each candidate:")
# Process candidates sequentially so that each candidate's progress bar is distinct.
for candidate in candidate_folders:
candidate_name = os.path.basename(candidate)
print(f"\nEvaluating candidate: {candidate_name}")
overall_score, total_tests, candidate_errors, test_failures, test_type_breakdown, all_test_scores, test_results = evaluate_candidate(
candidate, all_tests, pdf_basenames, args.force
)
# Always store test results for displaying jsonl file groupings
test_results_by_candidate[candidate_name] = test_results
# Group results by jsonl file for more accurate CI calculation
jsonl_results = {}
jsonl_scores = [] # List to store scores by jsonl file for CI calculation
jsonl_file_sizes = [] # List to store the number of tests per jsonl file
for test in all_tests:
# Get the jsonl file this test came from
jsonl_file = test_to_jsonl.get(test.id, "unknown")
if jsonl_file not in jsonl_results:
jsonl_results[jsonl_file] = {"total": 0, "passed": 0, "scores": []}
jsonl_results[jsonl_file]["total"] += 1
# Get the test result for this candidate if it exists
if not candidate_errors and hasattr(test, "pdf") and hasattr(test, "page"):
pdf_name = test.pdf
page = test.page
if pdf_name in test_results and page in test_results.get(pdf_name, {}):
for t, passed, _ in test_results[pdf_name][page]:
if t.id == test.id:
# Store the test score in its jsonl group
result_score = 1.0 if passed else 0.0
jsonl_results[jsonl_file]["scores"].append(result_score)
if passed:
jsonl_results[jsonl_file]["passed"] += 1
break
# Gather all the scores by jsonl file for CI calculation
for jsonl_file, results in jsonl_results.items():
if results["scores"]:
jsonl_file_sizes.append(len(results["scores"]))
jsonl_scores.extend(results["scores"])
# Calculate CI using the updated function with splits
if jsonl_scores:
ci = calculate_bootstrap_ci(jsonl_scores, n_bootstrap=n_bootstrap, ci_level=ci_level, splits=jsonl_file_sizes)
else:
ci = (0.0, 0.0)
summary.append((candidate_name, overall_score, total_tests, candidate_errors, test_failures, test_type_breakdown, ci, all_test_scores))
print(f"\nCandidate: {candidate_name}")
if candidate_errors:
for err in candidate_errors:
print(f" [ERROR] {err}")
else:
if test_failures:
for fail in test_failures:
print(f" [FAIL] {fail}")
# Calculate and show the per-category average score
jsonl_pass_rates = []
for _, results in jsonl_results.items():
if results["total"] > 0:
pass_rate = results["passed"] / results["total"]
jsonl_pass_rates.append(pass_rate)
per_category_score = sum(jsonl_pass_rates) / len(jsonl_pass_rates) if jsonl_pass_rates else 0.0
print(f" Average Score: {per_category_score * 100:.1f}% (95% CI: [{ci[0] * 100:.1f}%, {ci[1] * 100:.1f}%]) over {total_tests} tests.")
print("\n" + "=" * 60)
print("Final Summary with 95% Confidence Intervals:")
for idx, (candidate_name, _, total_tests, candidate_errors, _, test_type_breakdown, ci, _) in enumerate(summary):
# Group results by jsonl file
jsonl_results = {}
for test in all_tests:
# Get the jsonl file this test came from
jsonl_file = test_to_jsonl.get(test.id, "unknown")
if jsonl_file not in jsonl_results:
jsonl_results[jsonl_file] = {"total": 0, "passed": 0}
jsonl_results[jsonl_file]["total"] += 1
# Get the test result for this candidate if it exists
test_result = None
if not candidate_errors and hasattr(test, "pdf") and hasattr(test, "page"):
pdf_name = test.pdf
page = test.page
if pdf_name in test_results_by_candidate.get(candidate_name, {}) and page in test_results_by_candidate[candidate_name].get(pdf_name, {}):
for t, passed, _ in test_results_by_candidate[candidate_name][pdf_name][page]:
if t.id == test.id:
test_result = passed
break
if test_result:
jsonl_results[jsonl_file]["passed"] += 1
# Calculate new overall score as average of per-JSONL pass rates
jsonl_pass_rates = []
for jsonl_file, results in jsonl_results.items():
if results["total"] > 0:
pass_rate = results["passed"] / results["total"]
jsonl_pass_rates.append(pass_rate)
# New overall score is average of per-JSONL pass rates
new_overall_score = sum(jsonl_pass_rates) / len(jsonl_pass_rates) if jsonl_pass_rates else 0.0
# Update the overall_score in the summary list for later use (e.g., in permutation tests)
summary[idx] = (candidate_name, new_overall_score, total_tests, candidate_errors, summary[idx][4], test_type_breakdown, ci, summary[idx][7])
if candidate_errors:
status = "FAILED (errors)"
ciw_str = ""
else:
status = f"{new_overall_score * 100:0.1f}%"
# Use the CI that was calculated with proper category-based bootstrap
half_width = ((ci[1] - ci[0]) / 2) * 100
ciw_str = f"± {half_width:0.1f}%"
print(f"{candidate_name:20s} : Average Score: {status} {ciw_str} (average of per-JSONL scores)")
# Sort the test types alphabetically
for ttype in sorted(test_type_breakdown.keys()):
scores = test_type_breakdown[ttype]
avg = sum(scores) / len(scores) * 100 if scores else 0.0
print(f" {ttype:8s}: {avg:0.1f}% average pass rate over {len(scores)} tests")
print("\n Results by JSONL file:")
for jsonl_file, results in sorted(jsonl_results.items()):
if results["total"] > 0:
pass_rate = (results["passed"] / results["total"]) * 100
print(f" {jsonl_file:30s}: {pass_rate:0.1f}% ({results['passed']}/{results['total']} tests)")
print("")
# Generate HTML report if requested
if args.test_report:
generate_html_report(test_results_by_candidate, pdf_folder, args.test_report, max_reports=args.max_reports, test_to_jsonl=test_to_jsonl)
# Output tests that failed across all candidates if requested
if args.output_failed:
# Identify tests that failed across all candidates
all_failed_tests = []
valid_candidates = [c for c in summary if not c[3]] # Skip candidates with errors
for test in all_tests:
# Track whether this test has any results
has_results = False
any_passed = False
for candidate_name, _, _, _, _, _, _, _ in valid_candidates:
# Get the test result for this candidate
test_result = None
if hasattr(test, "pdf") and hasattr(test, "page"):
pdf_name = test.pdf
page = test.page
if pdf_name in test_results_by_candidate.get(candidate_name, {}) and page in test_results_by_candidate[candidate_name].get(pdf_name, {}):
for t, passed, explanation in test_results_by_candidate[candidate_name][pdf_name][page]:
if t.id == test.id:
has_results = True
test_result = passed
if passed:
any_passed = True
break
# If we have results for this test and it never passed for any candidate, add it to the failed list
if has_results and not any_passed:
# Add to the list
all_failed_tests.append(test)
# If we have any failed tests, write them to the specified JSONL file
output_path = os.path.join(input_folder, args.output_failed) if not os.path.isabs(args.output_failed) else args.output_failed
if all_failed_tests:
save_tests(all_failed_tests, output_path)
print(f"\nOutput {len(all_failed_tests)} tests that failed across all candidates to {output_path}")
else:
print("\nNo tests failed across all candidates. No output file created.")
if __name__ == "__main__":
main()
+305
View File
@@ -0,0 +1,305 @@
import argparse
import asyncio
import base64
import glob
import importlib
import os
import tempfile
import traceback
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from pypdf import PdfReader
from tqdm import tqdm
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.image_utils import convert_image_to_pdf_bytes
def parse_method_arg(method_arg):
"""
Parse a method configuration string of the form:
method_name[:key=value[:key2=value2...]]
Returns:
(method_name, kwargs_dict, folder_name)
"""
parts = method_arg.split(":")
name = parts[0]
kwargs = {}
folder_name = name # Default folder name is the method name
for extra in parts[1:]:
if "=" in extra:
key, value = extra.split("=", 1)
if key == "name":
folder_name = value
continue
try:
converted = int(value)
except ValueError:
try:
converted = float(value)
except ValueError:
converted = value
kwargs[key] = converted
else:
raise ValueError(f"Extra argument '{extra}' is not in key=value format")
return name, kwargs, folder_name
# Wrapper to run synchronous functions in the event loop
async def run_sync_in_executor(func, executor, *args, **kwargs):
"""Run a synchronous function in the provided executor"""
loop = asyncio.get_running_loop()
return await loop.run_in_executor(executor, partial(func, *args, **kwargs))
async def process_pdf(pdf_path, page_num, method, kwargs, output_path, is_async, executor=None, use_executor=True, failfast=False):
"""Process a single PDF and save the result to output_path"""
try:
if is_async:
# Run async function directly
markdown = await method(pdf_path, page_num=page_num, **kwargs)
elif use_executor:
# Run synchronous function in the executor
markdown = await run_sync_in_executor(method, executor, pdf_path, page_num=page_num, **kwargs)
else:
# Run synchronous function directly without executor (when parallel=0)
markdown = method(pdf_path, page_num=page_num, **kwargs)
if markdown is None:
print(f"Warning, did not get output for {os.path.basename(output_path)}")
# Write blank to this file, so that it's marked as an error and not just skipped in evals
with open(output_path, "w") as out_f:
out_f.write("")
return False
# Write the markdown to the output file
with open(output_path, "w") as out_f:
out_f.write(markdown)
return True
except Exception as ex:
if failfast:
print(f"\n{'=' * 60}")
print(f"FATAL ERROR: Exception occurred while processing {os.path.basename(output_path)}")
print(f"PDF: {pdf_path}, Page: {page_num}")
print(f"{'=' * 60}")
print("\nFull stack trace:")
traceback.print_exc()
print(f"{'=' * 60}\n")
# Re-raise the exception to stop processing
raise
else:
print(f"Exception {str(ex)} occurred while processing {os.path.basename(output_path)}")
# Write blank to this file, so that it's marked as an error and not just skipped in evals
with open(output_path, "w") as out_f:
out_f.write("")
return False
async def process_pdfs(config, pdf_directory, data_directory, repeats, remove_text, force, max_parallel=None, failfast=False):
"""
Process PDFs using asyncio for both sync and async methods,
limiting the number of concurrent tasks to max_parallel.
"""
# When max_parallel is 0, run synchronously without any executor
# When max_parallel is > 0, create a thread pool executor
use_executor = max_parallel != 0
executor = ThreadPoolExecutor(max_workers=max_parallel) if use_executor else None
try:
for candidate in config.keys():
print(f"Starting conversion using {candidate} with kwargs: {config[candidate]['kwargs']}")
folder_name = config[candidate]["folder_name"]
candidate_output_dir = os.path.join(data_directory, folder_name)
os.makedirs(candidate_output_dir, exist_ok=True)
method = config[candidate]["method"]
kwargs = config[candidate]["kwargs"]
is_async = asyncio.iscoroutinefunction(method)
# Use recursive glob to support nested PDFs
all_pdfs = glob.glob(os.path.join(pdf_directory, "**/*.pdf"), recursive=True)
all_pdfs.sort()
# Prepare all tasks
tasks = []
task_descriptions = {}
for pdf_path in all_pdfs:
pdf = PdfReader(pdf_path)
num_pages = len(pdf.pages)
base_name = os.path.basename(pdf_path).replace(".pdf", "")
# Determine the PDF's relative folder path (e.g. "arxiv_data") relative to pdf_directory
relative_pdf_path = os.path.relpath(pdf_path, pdf_directory)
pdf_relative_dir = os.path.dirname(relative_pdf_path)
if remove_text:
print(f"Converting {pdf_path} into images to remove text-content...")
# Generate image files from each page
temp_image_files = []
try:
for page_num in range(1, num_pages + 1):
# Get base64 PNG data for the current page
base64_png = render_pdf_to_base64png(pdf_path, page_num, target_longest_image_dim=2048)
# Decode base64 and save to temporary file
temp_img = tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False)
temp_img.write(base64.b64decode(base64_png))
temp_img.close()
temp_image_files.append(temp_img.name)
# Convert all images to a single PDF using our enhanced function
pdf_bytes = convert_image_to_pdf_bytes(temp_image_files)
# Write the PDF bytes to a temporary file
temp_pdf = tempfile.NamedTemporaryFile("wb", suffix=".pdf", delete=False)
temp_pdf.write(pdf_bytes)
temp_pdf.close()
# Update pdf_path to the new file
pdf_path = temp_pdf.name
finally:
# Clean up temporary image files
for temp_file in temp_image_files:
try:
os.remove(temp_file)
except Exception as e:
print(f"Warning: Failed to remove temporary file {temp_file}: {e}")
for repeat in range(1, repeats + 1):
for page_num in range(1, num_pages + 1):
output_filename = f"{base_name}_pg{page_num}_repeat{repeat}.md"
# Preserve the relative folder structure in the output directory
candidate_pdf_dir = os.path.join(candidate_output_dir, pdf_relative_dir)
os.makedirs(candidate_pdf_dir, exist_ok=True)
output_path = os.path.join(candidate_pdf_dir, output_filename)
if os.path.exists(output_path) and not force:
print(f"Skipping {base_name}_pg{page_num}_repeat{repeat} for {candidate}, file already exists")
print("Rerun with --force flag to force regeneration")
continue
task = process_pdf(pdf_path, page_num, method, kwargs, output_path, is_async, executor, use_executor, failfast)
tasks.append(task)
task_descriptions[id(task)] = f"{base_name}_pg{page_num}_repeat{repeat} ({candidate})"
# Process tasks with semaphore to limit concurrency
# When max_parallel is 0, set semaphore to 1 to run sequentially
semaphore = asyncio.Semaphore(max_parallel if max_parallel else 1)
async def process_with_semaphore(task):
async with semaphore:
return await task
# Wrap each task with the semaphore
limited_tasks = [process_with_semaphore(task) for task in tasks]
# Process tasks with progress bar
if limited_tasks:
completed = 0
with tqdm(total=len(limited_tasks), desc=f"Processing {candidate}") as pbar:
# When parallel=0, tasks complete synchronously and we need to handle them differently
if max_parallel == 0:
# Process tasks sequentially with immediate progress updates
for task in limited_tasks:
try:
result = await task
if result:
completed += 1
except Exception as e:
print(f"Task failed: {e}")
finally:
pbar.update(1)
else:
# Use as_completed for parallel processing
for task in asyncio.as_completed(limited_tasks):
try:
result = await task
if result:
completed += 1
except Exception as e:
print(f"Task failed: {e}")
finally:
pbar.update(1)
print(f"Completed {completed} out of {len(limited_tasks)} tasks for {candidate}")
finally:
# Clean up the executor
if executor:
executor.shutdown(wait=False)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Run PDF conversion using specified OCR methods and extra parameters.")
parser.add_argument(
"methods",
nargs="+",
help="Methods to run in the format method[:key=value ...]. "
"Example: gotocr mineru:temperature=2 marker:u=3. "
"Use 'name=folder_name' to specify a custom output folder name.",
)
parser.add_argument("--repeats", type=int, default=1, help="Number of times to repeat the conversion for each PDF.")
parser.add_argument(
"--dir",
type=str,
default=os.path.join(os.path.dirname(__file__), "sample_data"),
help="Path to the data folder in which to save outputs, pdfs should be in /pdfs folder within it.",
)
parser.add_argument("--force", action="store_true", default=False, help="Force regenerating of output files, even if they already exist")
parser.add_argument("--parallel", type=int, default=1, help="Maximum number of concurrent tasks")
parser.add_argument(
"--remove_text",
action="store_true",
help="When your PDF gets processed, we will take a screenshot of it first, to erase any text content in it. This would disable document-anchoring for olmocr.",
)
parser.add_argument(
"--failfast",
action="store_true",
default=False,
help="Fail immediately and print full stack trace if any page generation throws an exception",
)
args = parser.parse_args()
# Mapping of method names to a tuple: (module path, function name)
available_methods = {
"olmocr_pipeline": ("olmocr.bench.runners.run_olmocr_pipeline", "run_olmocr_pipeline"),
"gotocr": ("olmocr.bench.runners.run_gotocr", "run_gotocr"),
"nanonetsocr": ("olmocr.bench.runners.run_nanonetsocr", "run_nanonetsocr"),
"nanonetsocr_2": ("olmocr.bench.runners.run_nanonetsocr_2", "run_server"),
"marker": ("olmocr.bench.runners.run_marker", "run_marker"),
"mineru": ("olmocr.bench.runners.run_mineru", "run_mineru"),
"chatgpt": ("olmocr.bench.runners.run_chatgpt", "run_chatgpt"),
"gemini": ("olmocr.bench.runners.run_gemini", "run_gemini"),
"mistral": ("olmocr.bench.runners.run_mistral", "run_mistral"),
"docling": ("olmocr.bench.runners.run_docling", "run_docling"),
"dotsocr": ("olmocr.bench.runners.run_dotsocr", "run_dotsocr"),
"rolmocr": ("olmocr.bench.runners.run_rolmocr", "run_rolmocr"),
"paddlepaddle": ("olmocr.bench.runners.run_paddlepaddle", "run_paddlepaddle"),
"paddlevl": ("olmocr.bench.runners.run_paddlevl", "run_paddlevl"),
"transformers": ("olmocr.bench.runners.run_transformers", "run_transformers"),
"server": ("olmocr.bench.runners.run_server", "run_server"),
}
# Build config by importing only requested methods.
config = {}
for method_arg in args.methods:
method_name, extra_kwargs, folder_name = parse_method_arg(method_arg)
if method_name not in available_methods:
parser.error(f"Unknown method: {method_name}. " f"Available methods: {', '.join(available_methods.keys())}")
module_path, function_name = available_methods[method_name]
# Dynamically import the module and get the function.
module = importlib.import_module(module_path)
function = getattr(module, function_name)
config[method_name] = {"method": function, "kwargs": extra_kwargs, "folder_name": folder_name}
data_directory = args.dir
pdf_directory = os.path.join(data_directory, "pdfs")
# Run the async process function with the parallel and failfast arguments
asyncio.run(process_pdfs(config, pdf_directory, data_directory, args.repeats, args.remove_text, args.force, args.parallel, args.failfast))
+1
View File
@@ -0,0 +1 @@
from .render import compare_rendered_equations, render_equation
+1
View File
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t(require("katex")):"function"==typeof define&&define.amd?define(["katex"],t):"object"==typeof exports?exports.renderMathInElement=t(require("katex")):e.renderMathInElement=t(e.katex)}("undefined"!=typeof self?self:this,(function(e){return function(){"use strict";var t={757:function(t){t.exports=e}},n={};function r(e){var o=n[e];if(void 0!==o)return o.exports;var i=n[e]={exports:{}};return t[e](i,i.exports,r),i.exports}r.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return r.d(t,{a:t}),t},r.d=function(e,t){for(var n in t)r.o(t,n)&&!r.o(e,n)&&Object.defineProperty(e,n,{enumerable:!0,get:t[n]})},r.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)};var o={};r.d(o,{default:function(){return p}});var i=r(757),a=r.n(i);const l=function(e,t,n){let r=n,o=0;const i=e.length;for(;r<t.length;){const n=t[r];if(o<=0&&t.slice(r,r+i)===e)return r;"\\"===n?r++:"{"===n?o++:"}"===n&&o--,r++}return-1},s=/^\\begin{/;var d=function(e,t){let n;const r=[],o=new RegExp("("+t.map((e=>e.left.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"))).join("|")+")");for(;n=e.search(o),-1!==n;){n>0&&(r.push({type:"text",data:e.slice(0,n)}),e=e.slice(n));const o=t.findIndex((t=>e.startsWith(t.left)));if(n=l(t[o].right,e,t[o].left.length),-1===n)break;const i=e.slice(0,n+t[o].right.length),a=s.test(i)?i:e.slice(t[o].left.length,n);r.push({type:"math",data:a,rawData:i,display:t[o].display}),e=e.slice(n+t[o].right.length)}return""!==e&&r.push({type:"text",data:e}),r};const c=function(e,t){const n=d(e,t.delimiters);if(1===n.length&&"text"===n[0].type)return null;const r=document.createDocumentFragment();for(let e=0;e<n.length;e++)if("text"===n[e].type)r.appendChild(document.createTextNode(n[e].data));else{const o=document.createElement("span");let i=n[e].data;t.displayMode=n[e].display;try{t.preProcess&&(i=t.preProcess(i)),a().render(i,o,t)}catch(o){if(!(o instanceof a().ParseError))throw o;t.errorCallback("KaTeX auto-render: Failed to parse `"+n[e].data+"` with ",o),r.appendChild(document.createTextNode(n[e].rawData));continue}r.appendChild(o)}return r},f=function(e,t){for(let n=0;n<e.childNodes.length;n++){const r=e.childNodes[n];if(3===r.nodeType){let o=r.textContent,i=r.nextSibling,a=0;for(;i&&i.nodeType===Node.TEXT_NODE;)o+=i.textContent,i=i.nextSibling,a++;const l=c(o,t);if(l){for(let e=0;e<a;e++)r.nextSibling.remove();n+=l.childNodes.length-1,e.replaceChild(l,r)}else n+=a}else if(1===r.nodeType){const e=" "+r.className+" ";-1===t.ignoredTags.indexOf(r.nodeName.toLowerCase())&&t.ignoredClasses.every((t=>-1===e.indexOf(" "+t+" ")))&&f(r,t)}}};var p=function(e,t){if(!e)throw new Error("No element provided to render");const n={};for(const e in t)t.hasOwnProperty(e)&&(n[e]=t[e]);n.delimiters=n.delimiters||[{left:"$$",right:"$$",display:!0},{left:"\\(",right:"\\)",display:!1},{left:"\\begin{equation}",right:"\\end{equation}",display:!0},{left:"\\begin{align}",right:"\\end{align}",display:!0},{left:"\\begin{alignat}",right:"\\end{alignat}",display:!0},{left:"\\begin{gather}",right:"\\end{gather}",display:!0},{left:"\\begin{CD}",right:"\\end{CD}",display:!0},{left:"\\[",right:"\\]",display:!0}],n.ignoredTags=n.ignoredTags||["script","noscript","style","textarea","pre","code","option"],n.ignoredClasses=n.ignoredClasses||[],n.errorCallback=n.errorCallback||console.error,n.macros=n.macros||{},f(e,n)};return o=o.default}()}));
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+553
View File
@@ -0,0 +1,553 @@
#!/usr/bin/env python3
"""
Extract inner-most spans and their bounding boxes, and the MathML output,
from rendered LaTeX equations using Playwright and KaTeX.
Caching is maintained via a SHA1-based hash stored in a sqlite database.
Requirements:
pip install playwright
python -m playwright install chromium
Place katex.min.css and katex.min.js in the same directory as this script
"""
import atexit
import hashlib
import json
import os
import pathlib
import re
import sqlite3
import threading
import unittest
import weakref
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, replace
from typing import List, Optional
from playwright.sync_api import Error as PlaywrightError
from playwright.sync_api import sync_playwright
# --- New SQLite Cache Implementation ---
class EquationCache:
def __init__(self, db_path: Optional[str] = None):
if db_path is None:
# Use the same cache directory as before
cache_dir = pathlib.Path.home() / ".cache" / "olmocr" / "bench" / "equations"
cache_dir.mkdir(parents=True, exist_ok=True)
db_path = str(cache_dir / "cache.db")
self.db_path = db_path
self.lock = threading.Lock()
self._init_db()
def _init_db(self):
with self.lock:
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
# Added an 'error' column to store rendering errors
c.execute("""
CREATE TABLE IF NOT EXISTS equations (
eq_hash TEXT PRIMARY KEY,
mathml TEXT,
spans TEXT,
error TEXT
)
""")
conn.commit()
conn.close()
def load(self, eq_hash: str) -> Optional["RenderedEquation"]:
with self.lock:
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("SELECT mathml, spans, error FROM equations WHERE eq_hash = ?", (eq_hash,))
row = c.fetchone()
conn.close()
if row:
mathml, spans_json, error = row
if error:
# In error cases, we return an instance with error set and no spans.
return RenderedEquation(mathml=mathml, spans=[], error=error)
else:
spans_data = json.loads(spans_json)
spans = [
SpanInfo(
text=s["text"],
bounding_box=BoundingBox(
x=s["boundingBox"]["x"],
y=s["boundingBox"]["y"],
width=s["boundingBox"]["width"],
height=s["boundingBox"]["height"],
),
)
for s in spans_data
]
return RenderedEquation(mathml=mathml, spans=spans)
return None
def save(self, eq_hash: str, rendered_eq: "RenderedEquation"):
spans_data = [
{
"text": span.text,
"boundingBox": {
"x": span.bounding_box.x,
"y": span.bounding_box.y,
"width": span.bounding_box.width,
"height": span.bounding_box.height,
},
}
for span in rendered_eq.spans
]
spans_json = json.dumps(spans_data)
with self.lock:
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute(
"INSERT OR REPLACE INTO equations (eq_hash, mathml, spans, error) VALUES (?, ?, ?, ?)",
(eq_hash, rendered_eq.mathml, spans_json, rendered_eq.error),
)
conn.commit()
conn.close()
def clear(self):
with self.lock:
conn = sqlite3.connect(self.db_path)
c = conn.cursor()
c.execute("DELETE FROM equations")
conn.commit()
conn.close()
# Global instance of EquationCache
equation_cache = EquationCache()
# --- End SQLite Cache Implementation ---
@dataclass(frozen=True)
class BoundingBox:
x: float
y: float
width: float
height: float
@dataclass(frozen=True)
class SpanInfo:
text: str
bounding_box: BoundingBox
@dataclass(frozen=True)
class RenderedEquation:
mathml: str
spans: List[SpanInfo]
error: Optional[str] = None # New field to store error messages if rendering fails
def get_equation_hash(equation, bg_color="white", text_color="black", font_size=24):
"""
Calculate SHA1 hash of the equation string and rendering parameters.
"""
params_str = f"{equation}|{bg_color}|{text_color}|{font_size}"
return hashlib.sha1(params_str.encode("utf-8")).hexdigest()
# Thread-local storage for browser instances in the executor threads
_thread_local = threading.local()
# Global thread pool executor with a fixed number of threads
# Each thread will maintain its own Playwright instance
_render_executor = ThreadPoolExecutor(max_workers=8, thread_name_prefix="playwright-render")
def _cleanup_executor():
"""Cleanup function to shutdown the executor on exit."""
_render_executor.shutdown(wait=False)
# Register cleanup at exit
atexit.register(_cleanup_executor)
def _cleanup_playwright(playwright, browser):
print("Cleaning up", playwright)
try:
browser.close()
except Exception:
pass
try:
playwright.stop()
except Exception:
pass
class _BrowserOwner:
def __init__(self):
p = sync_playwright().start()
b = p.chromium.launch()
self.p = p
self.browser = b
self._closed = False
# Important: don't capture `self` or globals in the finalizer
self._finalizer = weakref.finalize(self, _cleanup_playwright, p, b)
def close_now(self):
if not self._closed:
self._closed = True
self._finalizer() # idempotent; runs at most once
def _get_thread_local_browser():
"""Get or create a browser instance for the current thread."""
owner = getattr(_thread_local, "owner", None)
if owner is None:
owner = _BrowserOwner()
_thread_local.owner = owner
return owner
def _render_in_executor(equation, bg_color, text_color, font_size, use_cache, debug_dom, eq_hash):
"""
Function to be run in the executor thread pool.
Each thread maintains its own Playwright instance.
"""
owner = _get_thread_local_browser()
ctx = owner.browser.new_context(viewport={"width": 800, "height": 400})
try:
return _do_render(ctx, equation, bg_color, text_color, font_size, debug_dom)
finally:
try:
ctx.close()
except Exception:
pass
def _do_render(context, equation, bg_color, text_color, font_size, debug_dom):
"""
Internal rendering function that uses a provided browser context.
"""
# Escape the equation for use in a JavaScript string.
escaped_equation = json.dumps(equation)
# Get local paths for KaTeX files.
script_dir = os.path.dirname(os.path.abspath(__file__))
katex_css_path = os.path.join(script_dir, "katex.min.css")
katex_js_path = os.path.join(script_dir, "katex.min.js")
if not os.path.exists(katex_css_path) or not os.path.exists(katex_js_path):
raise FileNotFoundError(f"KaTeX files not found. Please ensure katex.min.css and katex.min.js are in {script_dir}")
# Create a new page.
page = context.new_page()
# Basic HTML structure for rendering.
page_html = f"""
<!DOCTYPE html>
<html>
<head>
<style>
body {{
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: {bg_color};
color: {text_color};
}}
#equation-container {{
padding: 0;
font-size: {font_size}px;
}}
</style>
</head>
<body>
<div id="equation-container"></div>
</body>
</html>
"""
page.set_content(page_html)
page.add_style_tag(path=katex_css_path)
page.add_script_tag(path=katex_js_path)
page.wait_for_load_state("networkidle", timeout=0)
katex_loaded = page.evaluate("typeof katex !== 'undefined'")
if not katex_loaded:
page.close()
raise RuntimeError("KaTeX library failed to load. Check your katex.min.js file.")
try:
error_message = page.evaluate(f"""
() => {{
try {{
katex.render({escaped_equation}, document.getElementById("equation-container"), {{
displayMode: true,
throwOnError: true
}});
return null;
}} catch (error) {{
console.error("KaTeX error:", error.message);
return error.message;
}}
}}
""")
except PlaywrightError as ex:
print(escaped_equation)
error_message = str(ex)
page.close()
raise
if error_message:
print(f"Error rendering equation: '{equation}'")
print(error_message)
# Return error result
page.close()
return RenderedEquation(mathml=error_message, spans=[], error=error_message)
page.wait_for_selector(".katex", state="attached", timeout=0)
if debug_dom:
katex_dom_html = page.evaluate("""
() => {
return document.getElementById("equation-container").innerHTML;
}
""")
print("\n===== KaTeX DOM HTML =====")
print(katex_dom_html)
# Extract inner-most spans with non-whitespace text.
spans_info = page.evaluate("""
() => {
const spans = Array.from(document.querySelectorAll('span'));
const list = [];
spans.forEach(span => {
if (span.children.length === 0 && /\\S/.test(span.textContent)) {
const rect = span.getBoundingClientRect();
list.push({
text: span.textContent.trim(),
boundingBox: {
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height
}
});
}
});
return list;
}
""")
if debug_dom:
print("\n===== Extracted Span Information =====")
print(spans_info)
# Extract MathML output (if available) from the KaTeX output.
mathml = page.evaluate("""
() => {
const mathElem = document.querySelector('.katex-mathml math');
return mathElem ? mathElem.outerHTML : "";
}
""")
page.close()
rendered_eq = RenderedEquation(
mathml=mathml,
spans=[
SpanInfo(
text=s["text"],
bounding_box=BoundingBox(
x=s["boundingBox"]["x"],
y=s["boundingBox"]["y"],
width=s["boundingBox"]["width"],
height=s["boundingBox"]["height"],
),
)
for s in spans_info
],
)
return rendered_eq
def render_equation(
equation,
bg_color="white",
text_color="black",
font_size=24,
use_cache=True,
debug_dom=False,
):
"""
Render a LaTeX equation using Playwright and KaTeX, extract the inner-most span elements
along with their bounding boxes, and extract the MathML output generated by KaTeX.
This function uses a ThreadPoolExecutor with a fixed number of threads to prevent
resource leaks from unbounded thread creation.
"""
# Calculate hash for caching.
eq_hash = get_equation_hash(equation, bg_color, text_color, font_size)
# Try to load from SQLite cache.
if use_cache:
cached = equation_cache.load(eq_hash)
if cached is not None:
return cached
# Submit the rendering task to the thread pool executor
future = _render_executor.submit(_render_in_executor, equation, bg_color, text_color, font_size, use_cache, debug_dom, eq_hash)
# Wait for the result
rendered_eq = future.result()
# Save to cache if successful and caching is enabled
if use_cache and rendered_eq and not rendered_eq.error:
equation_cache.save(eq_hash, rendered_eq)
return rendered_eq
def compare_rendered_equations(reference: RenderedEquation, hypothesis: RenderedEquation) -> bool:
"""
Compare two RenderedEquation objects.
First, check if the normalized MathML of the hypothesis is contained within that of the reference.
If not, perform a neighbor-based matching on the spans.
"""
from bs4 import BeautifulSoup
def extract_inner(mathml: str) -> str:
try:
soup = BeautifulSoup(mathml, "xml")
semantics = soup.find("semantics")
if semantics:
inner_parts = [str(child) for child in semantics.contents if getattr(child, "name", None) != "annotation"]
return "".join(inner_parts)
else:
return str(soup)
except Exception as e:
print("Error parsing MathML with BeautifulSoup:", e)
print(mathml)
return mathml
# Filters out all spaces, include unicode zero and partial width spaces
def normalize(s: str) -> str:
return re.sub(r"[\s\u200a-\u200d\u2060\ufeff]+", "", s, flags=re.UNICODE)
reference_inner = normalize(extract_inner(reference.mathml))
hypothesis_inner = normalize(extract_inner(hypothesis.mathml))
if reference_inner in hypothesis_inner:
return True
H, R = reference.spans, hypothesis.spans
# Filter out any whitespace only spans and remove whitespace from the spans
H = [replace(span, text=normalize(span.text)) for span in H if normalize(span.text)]
R = [replace(span, text=normalize(span.text)) for span in R if normalize(span.text)]
def expand_span_info(span_info: SpanInfo) -> list[SpanInfo]:
total_elems = len(span_info.text)
return [
SpanInfo(
c,
BoundingBox(
span_info.bounding_box.x + (span_info.bounding_box.width * index) / total_elems,
span_info.bounding_box.y,
span_info.bounding_box.width / total_elems,
span_info.bounding_box.height,
),
)
for index, c in enumerate(span_info.text)
]
H = [span for sublist in H for span in expand_span_info(sublist)]
R = [span for sublist in R for span in expand_span_info(sublist)]
candidate_map = {}
for i, hspan in enumerate(H):
candidate_map[i] = [j for j, rsp in enumerate(R) if rsp.text == hspan.text]
if not candidate_map[i]:
return False
def compute_neighbors(spans, tol=5):
neighbors = {}
for i, span in enumerate(spans):
cx = span.bounding_box.x + span.bounding_box.width / 2
cy = span.bounding_box.y + span.bounding_box.height / 2
up = down = left = right = None
up_dist = down_dist = left_dist = right_dist = None
for j, other in enumerate(spans):
if i == j:
continue
ocx = other.bounding_box.x + other.bounding_box.width / 2
ocy = other.bounding_box.y + other.bounding_box.height / 2
if ocy < cy and abs(ocx - cx) <= tol:
dist = cy - ocy
if up is None or dist < up_dist:
up = j
up_dist = dist
if ocy > cy and abs(ocx - cx) <= tol:
dist = ocy - cy
if down is None or dist < down_dist:
down = j
down_dist = dist
if ocx < cx and abs(ocy - cy) <= tol:
dist = cx - ocx
if left is None or dist < left_dist:
left = j
left_dist = dist
if ocx > cx and abs(ocy - cy) <= tol:
dist = ocx - cx
if right is None or dist < right_dist:
right = j
right_dist = dist
neighbors[i] = {"up": up, "down": down, "left": left, "right": right}
return neighbors
hyp_neighbors = compute_neighbors(H)
ref_neighbors = compute_neighbors(R)
n = len(H)
used = [False] * len(R)
assignment = {}
def backtrack(i):
if i == n:
return True
for cand in candidate_map[i]:
if used[cand]:
continue
assignment[i] = cand
used[cand] = True
valid = True
for direction in ["up", "down", "left", "right"]:
hyp_nb = hyp_neighbors[i].get(direction)
ref_nb = ref_neighbors[cand].get(direction)
if hyp_nb is not None:
expected_text = H[hyp_nb].text
if ref_nb is None:
valid = False
break
if hyp_nb in assignment:
if assignment[hyp_nb] != ref_nb:
valid = False
break
else:
if R[ref_nb].text != expected_text:
valid = False
break
if valid:
if backtrack(i + 1):
return True
used[cand] = False
del assignment[i]
return False
return backtrack(0)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,140 @@
import argparse
import json
import os
from typing import Any, Dict
from openai import OpenAI
from olmocr.data.renderpdf import render_pdf_to_base64png
def verify_header_footer_match(
pdf_path: str,
page_num: int,
hea_foo_text: str,
model: str,
temperature: float = 0.1,
target_longest_image_dim: int = 2048,
) -> Dict[str, Any]:
"""
Verify if a headers and footers matches what appears in a PDF page.
Args:
pdf_path (str): Path to the PDF file
page_num (int): Page number to check (1-indexed)
model (str): OpenAI model to use
temperature (float): Temperature for API call
target_longest_image_dim (int): Target dimension for the image
Returns:
Dict with verification result
"""
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num, target_longest_image_dim=target_longest_image_dim)
if not os.getenv("OPENAI_API_KEY"):
raise SystemExit("You must specify an OPENAI_API_KEY environment variable")
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
prompt = f"""
This is a header and footer verification task.
I'm showing you a page from a PDF document containing headers and footers text.
Please verify if the headers or footers are exactly matches the below text.
{hea_foo_text}
Respond with a JSON object containing:
1. "status": "correct" or "incorrect"
2. "confidence": a value between 0 and 1 representing your confidence in the answer
3. "explanation": a brief explanation of why you believe the text is correct or incorrect
Focus specifically on checking if this exact header or footer expression appears in the document.
"""
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
],
}
],
# temperature=temperature,
response_format={"type": "json_object"},
# max_tokens=1000,
)
raw_response = response.choices[0].message.content
result = json.loads(raw_response)
return {
"pdf": pdf_path,
"math": hea_foo_text,
"status": result.get("status", "unknown"),
"confidence": result.get("confidence", 0),
"explanation": result.get("explanation", "No explanation provided"),
}
def process_jsonl_file(input_jsonl_path: str, output_jsonl_path: str, model: str = "o3-2025-04-16", temperature: float = 0.1) -> None:
"""
Process a JSONL file containing math expressions to verify.
Args:
input_jsonl_path (str): Path to input JSONL file
output_jsonl_path (str): Path to output JSONL file
model (str): OpenAI model to use
temperature (float): Temperature for API call
"""
processed_count = 0
with open(output_jsonl_path, "w") as out_file:
with open(input_jsonl_path, "r") as in_file:
for line_num, line in enumerate(in_file, 1):
try:
entry = json.loads(line.strip())
pdf_path = entry.get("pdf")
page_num = entry.get("page", 1)
text_expr = entry.get("text")
if not all([pdf_path, text_expr]):
print(f"Line {line_num}: Skipping entry due to missing required fields")
continue
print(f"Line {line_num}: Processing: {pdf_path}, page {page_num}")
try:
result = verify_header_footer_match(pdf_path=pdf_path, page_num=page_num, hea_foo_text=text_expr, model=model, temperature=temperature)
out_file.write(json.dumps(result) + "\n")
processed_count += 1
except Exception as e:
print(f"Line {line_num}: Error processing {pdf_path}: {str(e)}")
error_result = {"pdf": pdf_path, "text": text_expr, "status": "error", "explanation": str(e)}
out_file.write(json.dumps(error_result) + "\n")
processed_count += 1
except json.JSONDecodeError:
print(f"Line {line_num}: Invalid JSON, skipping")
print(f"Processed {processed_count} entries. Results saved to {output_jsonl_path}")
def main():
parser = argparse.ArgumentParser(description="Verify headers footers expressions in PDFs")
parser.add_argument("input_jsonl", help="Path to input JSONL file")
parser.add_argument("output_jsonl", help="Path to output JSONL file")
parser.add_argument("--model", default="o3-2025-04-16", help="OpenAI model to use")
parser.add_argument("--temperature", type=float, default=0.1, help="Temperature for API call")
args = parser.parse_args()
process_jsonl_file(input_jsonl_path=args.input_jsonl, output_jsonl_path=args.output_jsonl, model=args.model, temperature=args.temperature)
if __name__ == "__main__":
main()
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
import argparse
import json
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any, Dict
import openai
from tqdm import tqdm
from olmocr.data.renderpdf import render_pdf_to_base64png
def process_test_case(case: Dict[str, Any], client, pdf_dir: str, model: str = "gpt-4o") -> Dict[str, Any]:
"""
Send a request to GPT-4 asking if the before and after text appear in the same region.
Include the PDF image in the prompt.
Args:
case: A test case from the JSONL file
client: The OpenAI client
pdf_dir: Directory containing PDF files
model: The model to use
Returns:
The original case with the added response field
"""
before_text = case["before"]
after_text = case["after"]
pdf_path = os.path.join(pdf_dir, case["pdf"])
page_num = case["page"]
try:
# Render the PDF page to a base64-encoded PNG image
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num)
# Create messages with both text and image
messages = [
{"role": "system", "content": "You are an AI assistant analyzing text from PDFs."},
{
"role": "user",
"content": [
{
"type": "text",
"text": (
f"Does the text in the 'before' field and the 'after' field appear in the same region of the page? "
f"Look at the PDF image and determine if these texts are located near each other or in completely "
f"different parts of the page. Different regions could be the captions for different images, or inside of different insets or tables. However, appearing the same column of text, or in the naturally flowing next column of text is close enough.\n\n"
f"Before: {before_text}\n\n"
f"After: {after_text}\n\n"
f"Respond with 'YES' if they appear in the same region or column, and 'NO' if they appear in "
f"different regions. Then explain your reasoning in 1-2 sentences."
),
},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
],
},
]
# Call the API
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=0.0,
max_tokens=300,
)
# Add GPT-4's response to the case
case_with_response = case.copy()
case_with_response["gpt4_response"] = response.choices[0].message.content
return case_with_response
except Exception as e:
# In case of error, return the original case with an error message
case_with_response = case.copy()
case_with_response["gpt4_response"] = f"ERROR: {str(e)}"
# Print the error for debugging
print(f"Error processing {case.get('id', 'unknown')}: {str(e)}")
return case_with_response
def process_jsonl_file(input_file: str, output_file: str, api_key: str, pdf_dir: str, num_workers: int = 8, model: str = "gpt-4o") -> None:
"""
Process each line in the JSONL file by sending requests to GPT-4 in parallel.
Args:
input_file: Path to the input JSONL file
output_file: Path to write the output JSONL file with responses
api_key: OpenAI API key
pdf_dir: Directory containing PDF files
num_workers: Number of parallel workers
model: The model to use
"""
# Read all test cases from the input file
with open(input_file, "r") as f:
lines = f.readlines()
# Parse each line to get test cases
test_cases = []
for line in lines:
if line.strip():
test_cases.append(json.loads(line))
# Initialize OpenAI client
client = openai.OpenAI(api_key=api_key)
# Process test cases in parallel
results = []
with ThreadPoolExecutor(max_workers=num_workers) as executor:
# Submit all tasks
future_to_case = {executor.submit(process_test_case, case, client, pdf_dir, model): case for case in test_cases}
# Process results as they complete
for future in tqdm(as_completed(future_to_case), total=len(test_cases), desc="Processing test cases"):
try:
result = future.result()
results.append(result)
except Exception as e:
case = future_to_case[future]
print(f"Error processing case {case.get('id', 'unknown')}: {str(e)}")
# Add failed case with error message
case["gpt4_response"] = f"PROCESSING_ERROR: {str(e)}"
results.append(case)
# Filter for cases where GPT-4 responded with "NO"
no_responses = [result for result in results if "gpt4_response" in result and result["gpt4_response"].startswith("NO")]
# Write filtered results to output file
with open(output_file, "w") as f:
for result in no_responses:
f.write(json.dumps(result) + "\n")
print(f"Processed {len(results)} test cases. Found {len(no_responses)} cases with 'NO' responses. Results written to {output_file}")
def main():
parser = argparse.ArgumentParser(description="Process multi_column.jsonl with GPT-4 to check text regions")
parser.add_argument("--input", default="/home/ubuntu/olmocr/olmOCR-bench/bench_data/multi_column.jsonl", help="Path to input JSONL file")
parser.add_argument("--output", default="/home/ubuntu/olmocr/olmOCR-bench/bench_data/multi_column_gpt4_regions.jsonl", help="Path to output JSONL file")
parser.add_argument("--pdf-dir", default="/home/ubuntu/olmocr/olmOCR-bench/bench_data/pdfs", help="Directory containing the PDF files")
parser.add_argument("--workers", type=int, default=8, help="Number of parallel workers")
parser.add_argument("--model", default="gpt-4.1", help="OpenAI model to use")
parser.add_argument("--api-key", help="OpenAI API key (if not provided, uses OPENAI_API_KEY env var)")
args = parser.parse_args()
# Get API key from arguments or environment variable
api_key = args.api_key or os.environ.get("OPENAI_API_KEY")
if not api_key:
raise ValueError("OpenAI API key must be provided either via --api-key or OPENAI_API_KEY environment variable")
# Verify that the PDF directory exists
if not os.path.isdir(args.pdf_dir):
raise ValueError(f"PDF directory {args.pdf_dir} does not exist")
process_jsonl_file(input_file=args.input, output_file=args.output, api_key=api_key, pdf_dir=args.pdf_dir, num_workers=args.workers, model=args.model)
if __name__ == "__main__":
main()
+143
View File
@@ -0,0 +1,143 @@
import argparse
import json
import os
from typing import Any, Dict
from openai import OpenAI
from olmocr.data.renderpdf import render_pdf_to_base64png
def verify_latex_match(
pdf_path: str,
page_num: int,
latex_expression: str,
model: str = "gpt-4o-2024-08-06",
temperature: float = 0.1,
target_longest_image_dim: int = 2048,
) -> Dict[str, Any]:
"""
Verify if a LaTeX math expression matches what appears in a PDF page.
Args:
pdf_path (str): Path to the PDF file
page_num (int): Page number to check (1-indexed)
latex_expression (str): LaTeX expression to verify
model (str): OpenAI model to use
temperature (float): Temperature for API call
target_longest_image_dim (int): Target dimension for the image
Returns:
Dict with verification result
"""
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num, target_longest_image_dim=target_longest_image_dim)
if not os.getenv("OPENAI_API_KEY"):
raise SystemExit("You must specify an OPENAI_API_KEY environment variable")
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
prompt = f"""
This is a mathematical expression verification task.
I'm showing you a page from a PDF document containing mathematical expressions.
Please verify if the following LaTeX expression:
{latex_expression}
appears correctly in the document.
Respond with a JSON object containing:
1. "status": "correct" or "incorrect"
2. "confidence": a value between 0 and 1 representing your confidence in the answer
3. "explanation": a brief explanation of why you believe the expression is correct or incorrect
Focus specifically on checking if this exact mathematical expression appears in the document.
"""
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
],
}
],
# temperature=temperature,
response_format={"type": "json_object"},
# max_tokens=1000,
)
raw_response = response.choices[0].message.content
result = json.loads(raw_response)
return {
"pdf": pdf_path,
"math": latex_expression,
"status": result.get("status", "unknown"),
"confidence": result.get("confidence", 0),
"explanation": result.get("explanation", "No explanation provided"),
}
def process_jsonl_file(input_jsonl_path: str, output_jsonl_path: str, model: str = "o4-mini-2025-04-16", temperature: float = 0.1) -> None:
"""
Process a JSONL file containing math expressions to verify.
Args:
input_jsonl_path (str): Path to input JSONL file
output_jsonl_path (str): Path to output JSONL file
model (str): OpenAI model to use
temperature (float): Temperature for API call
"""
processed_count = 0
with open(output_jsonl_path, "w") as out_file:
with open(input_jsonl_path, "r") as in_file:
for line_num, line in enumerate(in_file, 1):
try:
entry = json.loads(line.strip())
pdf_path = entry.get("pdf")
page_num = entry.get("page", 1)
math_expr = entry.get("math")
if not all([pdf_path, math_expr]):
print(f"Line {line_num}: Skipping entry due to missing required fields")
continue
print(f"Line {line_num}: Processing: {pdf_path}, page {page_num}")
try:
result = verify_latex_match(pdf_path=pdf_path, page_num=page_num, latex_expression=math_expr, model=model, temperature=temperature)
out_file.write(json.dumps(result) + "\n")
processed_count += 1
except Exception as e:
print(f"Line {line_num}: Error processing {pdf_path}: {str(e)}")
error_result = {"pdf": pdf_path, "math": math_expr, "status": "error", "explanation": str(e)}
out_file.write(json.dumps(error_result) + "\n")
processed_count += 1
except json.JSONDecodeError:
print(f"Line {line_num}: Invalid JSON, skipping")
print(f"Processed {processed_count} entries. Results saved to {output_jsonl_path}")
def main():
parser = argparse.ArgumentParser(description="Verify LaTeX math expressions in PDFs")
parser.add_argument("input_jsonl", help="Path to input JSONL file")
parser.add_argument("output_jsonl", help="Path to output JSONL file")
parser.add_argument("--model", default="o4-mini-2025-04-16", help="OpenAI model to use")
parser.add_argument("--temperature", type=float, default=0.1, help="Temperature for API call")
args = parser.parse_args()
process_jsonl_file(input_jsonl_path=args.input_jsonl, output_jsonl_path=args.output_jsonl, model=args.model, temperature=args.temperature)
if __name__ == "__main__":
main()
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env python
import argparse
import json
import os
from collections import defaultdict
from pypdf import PdfReader, PdfWriter
def get_pdf_page_refs(dataset_jsonl):
"""
Parse dataset.jsonl to extract all PDF page references.
Returns a dict mapping (pdf_name, page_num) to a list of test IDs referencing that combination.
"""
pdf_page_tests = defaultdict(list)
with open(dataset_jsonl, "r") as f:
for line in f:
if not line.strip():
continue
try:
test = json.loads(line)
pdf_name = test.get("pdf")
page_num = test.get("page")
test_id = test.get("id")
if pdf_name and page_num and test_id:
pdf_page_tests[(pdf_name, page_num)].append(test_id)
except json.JSONDecodeError:
print(f"Warning: Could not parse line: {line}")
continue
return pdf_page_tests
def extract_single_page_pdfs(source_pdf_dir, target_pdf_dir, pdf_page_tests):
"""
Extract single page PDFs for each referenced (pdf_name, page_num) combination.
"""
os.makedirs(target_pdf_dir, exist_ok=True)
# Track all PDFs we need to process
processed_pairs = set()
for pdf_name, page_num in pdf_page_tests.keys():
if (pdf_name, page_num) in processed_pairs:
continue
source_pdf_path = os.path.join(source_pdf_dir, pdf_name)
if not os.path.exists(source_pdf_path):
print(f"Warning: Source PDF not found: {source_pdf_path}")
continue
try:
# Create a new single-page PDF
reader = PdfReader(source_pdf_path)
writer = PdfWriter()
# PDF pages are 0-indexed, but our references are 1-indexed
zero_indexed_page = page_num - 1
if zero_indexed_page >= len(reader.pages) or zero_indexed_page < 0:
print(f"Warning: Page {page_num} out of range for {pdf_name}")
continue
# Add the specified page to the writer
writer.add_page(reader.pages[zero_indexed_page])
# Create output filename
# Remove .pdf extension if it exists
base_name = pdf_name.rsplit(".", 1)[0] if pdf_name.lower().endswith(".pdf") else pdf_name
output_filename = f"{base_name}_pg{page_num}.pdf"
output_path = os.path.join(target_pdf_dir, output_filename)
# Write the new PDF
with open(output_path, "wb") as output_file:
writer.write(output_file)
processed_pairs.add((pdf_name, page_num))
print(f"Created single-page PDF: {output_path}")
except Exception as e:
print(f"Error processing {pdf_name} page {page_num}: {str(e)}")
return processed_pairs
def reorganize_test_outputs(source_data_dir, target_data_dir, processed_pairs):
"""
Copy and reorganize test outputs matching the processed PDF page combinations.
"""
# Create a dataset.jsonl with only the tests for pages we're keeping
source_dataset = os.path.join(source_data_dir, "dataset.jsonl")
target_dataset = os.path.join(target_data_dir, "dataset.jsonl")
# Only copy tests for PDFs we processed
if os.path.exists(source_dataset):
with open(source_dataset, "r") as source_f, open(target_dataset, "w") as target_f:
for line in source_f:
if not line.strip():
continue
try:
test = json.loads(line)
pdf_name = test.get("pdf")
page_num = test.get("page")
# Update the PDF name in the test to reflect our new naming convention
if (pdf_name, page_num) in processed_pairs:
base_name = pdf_name.rsplit(".", 1)[0] if pdf_name.lower().endswith(".pdf") else pdf_name
test["pdf"] = f"{base_name}_pg{page_num}.pdf"
# Since we've created single-page PDFs, update the page to 1
test["page"] = 1
target_f.write(json.dumps(test) + "\n")
except json.JSONDecodeError:
continue
def main():
parser = argparse.ArgumentParser(description="Extract single-page PDFs and reorganize test data")
parser.add_argument("--source_dir", type=str, required=True, help="Source directory containing the original sample_data structure")
parser.add_argument("--target_dir", type=str, required=True, help="Target directory to create the new data structure")
args = parser.parse_args()
source_data_dir = args.source_dir
target_data_dir = args.target_dir
# Create directory structure
os.makedirs(target_data_dir, exist_ok=True)
target_pdf_dir = os.path.join(target_data_dir, "pdfs")
# Get paths
source_dataset_path = os.path.join(source_data_dir, "dataset.jsonl")
source_pdf_dir = os.path.join(source_data_dir, "pdfs")
# Extract PDF page references from dataset
pdf_page_tests = get_pdf_page_refs(source_dataset_path)
print(f"Found {len(pdf_page_tests)} unique PDF page combinations referenced in tests")
# Extract single-page PDFs
processed_pairs = extract_single_page_pdfs(source_pdf_dir, target_pdf_dir, pdf_page_tests)
print(f"Processed {len(processed_pairs)} unique PDF pages")
# Reorganize test outputs
reorganize_test_outputs(source_data_dir, target_data_dir, processed_pairs)
print(f"Data extraction complete. New structure created in {target_data_dir}")
if __name__ == "__main__":
main()
+64
View File
@@ -0,0 +1,64 @@
# Rewrites all URLs in a dataset.jsonl file using a sql lite database lookup
import argparse
import json
import re
import sqlite3
from typing import Optional
def parse_pdf_hash(pretty_pdf_path: str) -> Optional[str]:
pattern = r"s3://ai2-s2-pdfs/([a-f0-9]{4})/([a-f0-9]+)\.pdf"
match = re.match(pattern, pretty_pdf_path)
if match:
return match.group(1) + match.group(2)
return None
def get_uri_from_db(db_path: str, pdf_hash: str) -> Optional[str]:
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT uri FROM pdf_mapping WHERE pdf_hash = ?", (pdf_hash,))
result = cursor.fetchone()
conn.close()
return result[0] if result else None
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Rewrites all URLs in a dataset.jsonl file using a sql lite database lookup")
parser.add_argument("jsonl", type=str, help="JSONL file containing s3 paths")
parser.add_argument("--db", type=str, required=True, help="Path to sqlite database mapping internal s3 urls to external ones")
parser.add_argument("--force", action="store_true", help="Path to sqlite database mapping internal s3 urls to external ones")
args = parser.parse_args()
data = []
skipped = 0
with open(args.jsonl, "r") as inpf:
for row in inpf:
if len(row.strip()) > 0:
j = json.loads(row)
assert j["url"]
hash = parse_pdf_hash(j["url"])
if hash:
url = get_uri_from_db(args.db, hash)
if url:
j["url"] = url
data.append(j)
else:
skipped += 1
else:
data.append(j)
print(data)
print(f"{skipped} entries were skipped!")
if not args.force:
print("Now run with --force to write data")
quit()
with open(args.jsonl, "w") as inpf:
for row in data:
print(json.dumps(row), file=inpf)
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python
import argparse
import glob
import json
import os
import sys
from collections import defaultdict
def get_rejected_tests(dataset_jsonl):
"""
Parse dataset.jsonl to identify rejected tests.
Returns:
- rejected_tests: Set of test IDs that were marked as rejected
- pdf_tests: Dict mapping PDF filenames to sets of test IDs
- test_pdf_map: Dict mapping test IDs to their PDF filenames
"""
rejected_tests = set()
pdf_tests = defaultdict(set)
test_pdf_map = {}
try:
with open(dataset_jsonl, "r") as f:
for line in f:
if not line.strip():
continue
try:
test = json.loads(line)
test_id = test.get("id")
pdf_name = test.get("pdf")
# Store the test in our mapping
if test_id and pdf_name:
pdf_tests[pdf_name].add(test_id)
test_pdf_map[test_id] = pdf_name
# Check if the test is marked as rejected
if test.get("checked", None) == "rejected":
rejected_tests.add(test_id)
except json.JSONDecodeError:
print(f"Warning: Could not parse line: {line}")
continue
except FileNotFoundError:
print(f"Error: Dataset file {dataset_jsonl} not found.")
sys.exit(1)
return rejected_tests, pdf_tests, test_pdf_map
def update_dataset(dataset_jsonl, rejected_tests, dry_run=True):
"""
Create a new dataset.jsonl without the rejected tests.
"""
temp_file = dataset_jsonl + ".temp"
removed_count = 0
try:
with open(dataset_jsonl, "r") as source, open(temp_file, "w") as target:
for line in source:
if not line.strip():
continue
try:
test = json.loads(line)
test_id = test.get("id")
if test_id in rejected_tests:
removed_count += 1
else:
target.write(line)
except json.JSONDecodeError:
continue
except FileNotFoundError:
print(f"Error: Dataset file {dataset_jsonl} not found.")
sys.exit(1)
if not dry_run:
os.replace(temp_file, dataset_jsonl)
else:
os.remove(temp_file)
return removed_count
def find_orphaned_pdfs(pdf_dir, pdf_tests, rejected_tests):
"""
Find PDF files that have all their tests rejected.
"""
orphaned_pdfs = []
for pdf_name, tests in pdf_tests.items():
# Check if all tests for this PDF are in the rejected list
if tests and all(test_id in rejected_tests for test_id in tests):
pdf_path = os.path.join(pdf_dir, pdf_name)
if os.path.exists(pdf_path):
orphaned_pdfs.append(pdf_path)
return orphaned_pdfs
def find_unreferenced_pdfs(pdf_dir, pdf_tests):
"""
Find PDF files in the pdf_dir that are not referenced by any test.
"""
unreferenced_pdfs = []
# List all PDFs in the directory (recursively)
for pdf_path in glob.glob(os.path.join(pdf_dir, "**", "*.pdf"), recursive=True):
# Get the relative path of the PDF from pdf_dir
pdf_name = os.path.relpath(pdf_path, pdf_dir)
if pdf_name not in pdf_tests:
unreferenced_pdfs.append(pdf_path)
return unreferenced_pdfs
def main():
parser = argparse.ArgumentParser(description="Delete rejected tests from dataset and orphaned/unreferenced PDFs")
parser.add_argument("--data_dir", type=str, required=True, help="Directory containing dataset.jsonl files and the pdfs/ folder")
parser.add_argument("--force", action="store_true", help="Perform actual deletion without confirmation")
args = parser.parse_args()
data_dir = args.data_dir
dry_run = not args.force
# Verify pdfs directory exists
pdf_dir = os.path.join(data_dir, "pdfs")
if not os.path.exists(pdf_dir):
print(f"Error: pdfs/ directory not found in {data_dir}")
sys.exit(1)
# Find all JSONL dataset files in the data_dir
dataset_files = glob.glob(os.path.join(data_dir, "*.jsonl"))
if not dataset_files:
print("No JSONL dataset files found.")
sys.exit(0)
# Global aggregation over all dataset files
global_rejected_tests = set()
global_pdf_tests = defaultdict(set)
global_test_pdf_map = {}
for dataset_file in dataset_files:
rejected_tests, pdf_tests, test_pdf_map = get_rejected_tests(dataset_file)
global_rejected_tests |= rejected_tests
for pdf_name, test_ids in pdf_tests.items():
global_pdf_tests[pdf_name].update(test_ids)
global_test_pdf_map.update(test_pdf_map)
total_tests = sum(len(test_ids) for test_ids in global_pdf_tests.values())
# Compute orphaned and unreferenced PDFs using global mapping
orphaned_pdfs = find_orphaned_pdfs(pdf_dir, global_pdf_tests, global_rejected_tests)
unreferenced_pdfs = find_unreferenced_pdfs(pdf_dir, global_pdf_tests)
# Print summary (global)
print("\n===== DELETION SUMMARY =====")
print(f"Mode: {'DRY RUN (no changes will be made)' if dry_run else 'FORCE (changes will be applied)'}")
print(f"Total tests: {total_tests}")
print(f"Tests marked as rejected: {len(global_rejected_tests)}")
print(f"PDF files with all tests rejected: {len(orphaned_pdfs)}")
print(f"PDF files not referenced by any tests: {len(unreferenced_pdfs)}")
if global_rejected_tests:
print("\nRejected tests:")
for test_id in sorted(global_rejected_tests):
print(f" - {test_id} (from {global_test_pdf_map.get(test_id, 'unknown')})")
if orphaned_pdfs:
print("\nPDF files to be deleted (all tests rejected):")
for pdf_path in sorted(orphaned_pdfs):
print(f" - {os.path.basename(pdf_path)}")
if unreferenced_pdfs:
print("\nPDF files to be deleted (unreferenced by any tests):")
for pdf_path in sorted(unreferenced_pdfs):
print(f" - {os.path.basename(pdf_path)}")
# If dry run, exit here
if dry_run and (global_rejected_tests or orphaned_pdfs or unreferenced_pdfs):
print("\nThis is a dry run. No changes have been made.")
print("To perform the actual deletion, run the script with the --force flag.")
return
# Confirm before deletion if there are items to delete
if global_rejected_tests or orphaned_pdfs or unreferenced_pdfs:
confirm = input("\nDo you want to proceed with deletion? (y/N): ")
if confirm.lower() not in ("y", "yes"):
print("Deletion cancelled.")
return
# Update each dataset file by removing rejected tests
for dataset_file in dataset_files:
removed_count = update_dataset(dataset_file, global_rejected_tests, dry_run=False)
print(f"Removed {removed_count} rejected tests from {os.path.basename(dataset_file)}")
# Delete orphaned PDFs
for pdf_path in orphaned_pdfs:
try:
os.remove(pdf_path)
print(f"Deleted orphaned PDF: {os.path.basename(pdf_path)}")
except OSError as e:
print(f"Error deleting {os.path.basename(pdf_path)}: {e}")
# Delete unreferenced PDFs
for pdf_path in unreferenced_pdfs:
try:
os.remove(pdf_path)
print(f"Deleted unreferenced PDF: {os.path.basename(pdf_path)}")
except OSError as e:
print(f"Error deleting {os.path.basename(pdf_path)}: {e}")
print("\nDeletion completed successfully.")
else:
print("\nNo rejected tests, orphaned PDFs, or unreferenced PDFs found. Nothing to delete.")
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
# This script goes to
# https://arxiv.org/list/math/recent?skip=0&show=2000
# and downloads all the source PDFs, as well as latex equivalents, and puts them together into
# Searching for:
# <a href="/pdf/2503.08675" title="Download PDF" id="pdf-2503.08675" aria-labelledby="pdf-2503.08675">pdf</a>
# a math_data folder
#!/usr/bin/env python3
import argparse
import io
import os
import re
import tarfile
import time
import requests
from tqdm import tqdm
def download_and_extract_source(paper_id, data_dir):
source_url = f"https://export.arxiv.org/src/{paper_id}"
print(f"Downloading source for {paper_id} from {source_url}...")
response = requests.get(source_url)
if response.status_code != 200:
print(f"Error downloading source for {paper_id}: HTTP {response.status_code}")
return False
# Try to open as a tar archive.
try:
file_obj = io.BytesIO(response.content)
with tarfile.open(fileobj=file_obj, mode="r:*") as tar:
# Filter for regular .tex files.
members = [m for m in tar.getmembers() if m.isfile() and m.name.endswith(".tex")]
print("Found TeX files:", [m.name for m in members])
if len(members) == 1:
member = members[0]
extracted = tar.extractfile(member)
if extracted is None:
print(f"Error extracting {paper_id}: Could not read the file from the archive.")
return False
content = extracted.read()
out_path = os.path.join(data_dir, f"{paper_id}.tex")
with open(out_path, "wb") as f:
f.write(content)
print(f"Saved tex source for {paper_id} as {out_path}")
return True
else:
print(f"Error: {paper_id} contains multiple .tex files or none. Skipping extraction.")
return False
except tarfile.ReadError:
# Not a tar archive; assume it's a single file.
out_path = os.path.join(data_dir, f"{paper_id}.tex")
with open(out_path, "wb") as f:
f.write(response.content)
print(f"Saved non-archive tex source for {paper_id} as {out_path}")
return True
def download_pdf(paper_id, data_dir):
pdf_url = f"https://export.arxiv.org/pdf/{paper_id}.pdf"
print(f"Downloading PDF for {paper_id} from {pdf_url}...")
response = requests.get(pdf_url)
if response.status_code != 200:
print(f"Error downloading PDF for {paper_id}: HTTP {response.status_code}")
return False
out_path = os.path.join(data_dir, f"{paper_id}.pdf")
with open(out_path, "wb") as f:
f.write(response.content)
print(f"Saved PDF for {paper_id} as {out_path}")
return True
def main():
parser = argparse.ArgumentParser(description="Download and extract arXiv LaTeX source files and PDFs only if both succeed.")
parser.add_argument(
"--url", type=str, default="https://arxiv.org/list/math/recent?skip=0&show=2000", help="URL of the arXiv list page to scrape (default: %(default)s)"
)
parser.add_argument("--data_dir", type=str, default="math_data/pdfs", help="Directory to save downloaded files (default: %(default)s)")
parser.add_argument("--pdf_only", action="store_true", help="Skip LaTeX verification and only download PDFs")
args = parser.parse_args()
if not os.path.exists(args.data_dir):
os.makedirs(args.data_dir)
print(f"Downloading list page from {args.url}...")
response = requests.get(args.url)
if response.status_code != 200:
print(f"Error downloading list page: HTTP {response.status_code}")
return
# Find all pdf links in the form: <a href="/pdf/2503.08675" ...>pdf</a>
pattern = re.compile(r'href="/pdf/(\d+\.\d+)"')
paper_ids = pattern.findall(response.text)
print(f"Found {len(paper_ids)} papers.")
# For each paper, download based on the mode
for paper_id in tqdm(paper_ids):
if args.pdf_only:
# Only download PDFs, skip LaTeX verification
pdf_success = download_pdf(paper_id, args.data_dir)
if not pdf_success:
print(f"Failed to download PDF for {paper_id}")
else:
# Original behavior: only keep files if both tex extraction and pdf download succeed
tex_success = download_and_extract_source(paper_id, args.data_dir)
if not tex_success:
print(f"Skipping PDF download for {paper_id} because tex extraction failed.")
continue
pdf_success = download_pdf(paper_id, args.data_dir)
if not pdf_success:
# Remove the tex file if the PDF download fails.
tex_path = os.path.join(args.data_dir, f"{paper_id}.tex")
if os.path.exists(tex_path):
os.remove(tex_path)
print(f"Removed tex file for {paper_id} because PDF download failed.")
time.sleep(3)
if __name__ == "__main__":
main()
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python3
"""
mine_blank_pages_gpt.py - Identify PDF documents with blank pages and copy them.
This script:
1. Takes a file containing S3 paths to PDF documents as input
2. For each PDF, renders a random page and uses GPT-4o with the exact same query as buildsilver.py
3. Identifies PDFs where the structured output has null natural_text
4. Copies those PDF files to a new output folder
Usage:
python mine_blank_pages_gpt.py --input_list path/to/s3_paths.txt --output_dir path/to/output --api_key your_openai_api_key
"""
import argparse
import json
import os
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
import boto3
import pypdf
from openai import OpenAI
from tqdm import tqdm
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.filter import PdfFilter
from olmocr.prompts import (
build_openai_silver_data_prompt,
openai_response_format_schema,
)
from olmocr.prompts.anchor import get_anchor_text
TARGET_IMAGE_DIM = 2048
def download_pdf_from_s3(s3_path: str, local_path: str) -> bool:
"""
Download a PDF file from S3.
Args:
s3_path: The S3 path (s3://bucket/path/to/file.pdf)
local_path: The local path to save the file
Returns:
bool: True if download was successful, False otherwise
"""
try:
# Parse S3 path
parts = s3_path.replace("s3://", "").split("/", 1)
bucket = parts[0]
key = parts[1]
# Create S3 client
s3 = boto3.client("s3")
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(local_path), exist_ok=True)
# Download file
s3.download_file(bucket, key, local_path)
return True
except Exception as e:
print(f"Error downloading {s3_path}: {str(e)}")
return False
def check_blank_page(pdf_path: str, page_num: int, api_key: str) -> Optional[bool]:
"""
Use GPT-4o with the exact same query as buildsilver.py to check if a page has null natural_text.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
api_key: OpenAI API key
Returns:
Optional[bool]: True if natural_text is null, False otherwise, None if detection fails
"""
# Initialize OpenAI client
client = OpenAI(api_key=api_key)
try:
# Render the PDF page as an image (render_pdf_to_base64png is 1-indexed)
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=TARGET_IMAGE_DIM)
# Get anchor text
anchor_text = get_anchor_text(pdf_path, page_num + 1, pdf_engine="pdfreport")
# Build the exact same prompt as buildsilver.py
response = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": build_openai_silver_data_prompt(anchor_text)},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
],
}
],
temperature=0.1,
max_tokens=3000,
logprobs=True,
top_logprobs=5,
response_format=openai_response_format_schema(),
)
if not response.choices or len(response.choices) == 0:
print(f"No response generated for {pdf_path} page {page_num}")
return None
# Parse the JSON response
response_text = response.choices[0].message.content
response_data = json.loads(response_text)
# Check if natural_text is null
is_blank = response_data.get("natural_text") is None
if is_blank:
print(f"Found blank page in {pdf_path} page {page_num + 1}")
return is_blank
except Exception as e:
print(f"Error checking {pdf_path} page {page_num}: {str(e)}")
return None
def process_pdf(s3_path: str, temp_dir: str, output_dir: str, api_key: str) -> bool:
"""
Process a single PDF from S3.
Args:
s3_path: S3 path to the PDF
temp_dir: Directory for temporary files
output_dir: Directory for output files
api_key: OpenAI API key
Returns:
bool: True if the PDF has a blank page and was copied, False otherwise
"""
# Extract filename from S3 path
pdf_filename = os.path.basename(s3_path)
local_pdf_path = os.path.join(temp_dir, pdf_filename)
# Download PDF from S3
if not download_pdf_from_s3(s3_path, local_pdf_path):
return False
pdf_filter = PdfFilter()
if pdf_filter.filter_out_pdf(local_pdf_path):
print(f"Filtering out {pdf_filename}")
return False
try:
# Read the PDF to get the number of pages
reader = pypdf.PdfReader(local_pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF {pdf_filename} has no pages")
return False
# Select a random page to check
page_num = random.randint(0, num_pages - 1)
page_num = random.choice([page_num, 0]) # Bias 50% of the time to do the first page
# Check if the page has null natural_text
is_blank = check_blank_page(local_pdf_path, page_num, api_key)
if is_blank:
# Extract just the blank page and save it as a new PDF
os.makedirs(output_dir, exist_ok=True)
# Create output filename with basename_pgnum.pdf format
pdf_basename = os.path.splitext(pdf_filename)[0]
output_pdf_path = os.path.join(output_dir, f"{pdf_basename}_pg{page_num+1}.pdf")
# Extract the single page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[page_num])
# Write the output PDF
with open(output_pdf_path, "wb") as output_file:
writer.write(output_file)
print(f"Extracted blank page {page_num+1} from {pdf_filename} to {os.path.basename(output_pdf_path)}")
return True
return False
except Exception as e:
print(f"Error processing {pdf_filename}: {str(e)}")
return False
finally:
if os.path.exists(local_pdf_path):
os.remove(local_pdf_path)
def main():
parser = argparse.ArgumentParser(description="Identify and copy PDFs with blank pages")
parser.add_argument("--input_list", required=True, help="Path to a file containing S3 paths to PDFs")
parser.add_argument("--output_dir", required=True, help="Directory to copy PDFs with blank pages")
parser.add_argument("--api_key", help="OpenAI API key (if not provided, will use OPENAI_API_KEY environment variable)")
parser.add_argument("--temp_dir", default="/tmp/mine_blank_pages", help="Directory for temporary files")
parser.add_argument("--max_pdfs", type=int, default=100, help="Maximum number of blank PDFs to find")
parser.add_argument("--parallel", type=int, default=1, help="Number of parallel workers (default: 1 for sequential)")
parser.add_argument("--reservoir_multiplier", type=int, default=100, help="Multiplier for reservoir sampling (default: 100x max_pdfs)")
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("OPENAI_API_KEY")
if not api_key:
print("Error: OpenAI API key not provided. Use --api_key or set OPENAI_API_KEY environment variable.")
return
os.makedirs(args.temp_dir, exist_ok=True)
os.makedirs(args.output_dir, exist_ok=True)
# Reservoir sampling to get random subset of PDFs
reservoir_size = args.max_pdfs * args.reservoir_multiplier
pdf_paths = []
n = 0 # Total number of items seen
print(f"Using reservoir sampling with size {reservoir_size}")
with open(args.input_list, "r") as f:
for line in f:
n += 1
path = line.strip()
if not path:
continue
if len(pdf_paths) < reservoir_size:
pdf_paths.append(path)
else:
# Randomly decide whether to include this item
s = random.randint(1, n)
if s <= reservoir_size:
pdf_paths[s - 1] = path
# Shuffle the reservoir
random.shuffle(pdf_paths)
print(f"Sampled {len(pdf_paths)} PDF paths from {n} total paths")
blank_pdfs_found = 0
if args.parallel > 1:
# Parallel processing
print(f"Processing PDFs with {args.parallel} parallel workers")
with ThreadPoolExecutor(max_workers=args.parallel) as executor:
futures = []
# Submit all tasks
for s3_path in pdf_paths:
if blank_pdfs_found >= args.max_pdfs:
break
future = executor.submit(process_pdf, s3_path, args.temp_dir, args.output_dir, api_key)
futures.append(future)
# Process results as they complete
with tqdm(total=min(len(pdf_paths), args.max_pdfs), desc="Processing PDFs") as pbar:
for future in as_completed(futures):
try:
result = future.result()
if result:
blank_pdfs_found += 1
pbar.update(1)
if blank_pdfs_found >= args.max_pdfs:
print(f"Reached maximum number of blank PDFs ({args.max_pdfs}), stopping")
# Cancel remaining futures
for f in futures:
f.cancel()
break
except Exception as e:
print(f"Error in parallel processing: {str(e)}")
else:
# Sequential processing
for s3_path in tqdm(pdf_paths, desc="Processing PDFs"):
if process_pdf(s3_path, args.temp_dir, args.output_dir, api_key):
blank_pdfs_found += 1
if blank_pdfs_found >= args.max_pdfs:
print(f"Reached maximum number of blank PDFs ({args.max_pdfs}), stopping")
break
print(f"Found and copied {blank_pdfs_found} PDFs with blank pages to {args.output_dir}")
if __name__ == "__main__":
main()
+226
View File
@@ -0,0 +1,226 @@
import argparse
import base64
import os
import re
import time
from collections import Counter
from difflib import SequenceMatcher
import syntok.segmenter as segmenter
from google import genai
from google.genai import types
from olmocr.bench.tests import TextPresenceTest, save_tests
from olmocr.data.renderpdf import render_pdf_to_base64png
LABEL_WIDTH = 8 # fixed width for printing labels
# Uses a gemini prompt to get the most likely clean sentence from a pdf page
last_gemini_call = time.perf_counter()
def clean_base_sentence(pdf_path: str, page_num: int, base_sentence: str) -> str:
client = genai.Client(
api_key=os.environ.get("GEMINI_API_KEY"),
)
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num, target_longest_image_dim=2048)
image_part = types.Part(inline_data=types.Blob(mime_type="image/png", data=base64.b64decode(image_base64)))
model = "gemini-2.0-flash-thinking-exp-01-21" # Consider using a more stable model for production
# model="gemini-2.0-flash-001"
contents = [
types.Content(
role="user",
parts=[
image_part,
types.Part.from_text(
text=f"""Base: {base_sentence}
Consider the sentence labeled "Base" above in the document image attached. What is the correct reading of this document within the image of the page? I need it to be exact down to the individual character and that's very important to get right. It needs to match the picture, not the provided text. Please just output the correct full sentence exactly how it appears in the document image and nothing else. You can merge hyphenated words back together, and don't output any new lines."""
),
],
),
]
generate_content_config = types.GenerateContentConfig(
temperature=0.7,
top_p=0.95,
top_k=64,
max_output_tokens=500,
response_mime_type="text/plain",
)
response = client.models.generate_content(
model=model,
contents=contents,
config=generate_content_config,
)
# Basic rate limitting
global last_gemini_call
if time.perf_counter() - last_gemini_call < 6:
time.sleep(6 - (time.perf_counter() - last_gemini_call))
last_gemini_call = time.perf_counter()
# Return response
if response is not None and response.candidates is not None and len(response.candidates) > 0:
return response.candidates[0].content.parts[0].text
else:
return None
def parse_sentences(text: str) -> list[str]:
"""
Splits a text into a list of sentence strings using syntok.
Preserves original spacing and punctuation.
"""
sentences = []
for paragraph in segmenter.process(text):
for sentence in paragraph:
# Reconstruct the sentence with original spacing
sentence_str = ""
for token in sentence:
sentence_str += token.spacing + token.value
# Trim any leading whitespace
sentence_str = sentence_str.lstrip()
sentences.append(sentence_str)
return sentences
def compare_votes_for_file(base_pdf_file: str, base_pdf_page: int, base_text: str, candidate_texts: list[str], max_diffs: int) -> None:
"""
For each sentence in the base text, finds the best matching sentence from
each candidate text (using a similarity threshold). If any candidate sentences
differ from the base sentence, collects that diff (base sentence plus variant
votes) for later printing. At the end, prints only the top N diffs (by total vote count)
for the file.
Comparison is case-insensitive, but output preserves original capitalization.
"""
base_sentences = parse_sentences(base_text)
# Parse all candidate texts into lists of sentences
candidate_sentences_list = [parse_sentences(ct) for ct in candidate_texts]
diffs = [] # list to hold diff entries
for b_sentence in base_sentences:
b_sentence = b_sentence.replace("\n", " ").strip()
votes = []
for c_sentences in candidate_sentences_list:
best_ratio = 0.0
best_candidate = None
# Find the candidate sentence with the highest similarity to b_sentence
# using case-insensitive comparison
for c_sentence in c_sentences:
ratio = SequenceMatcher(None, b_sentence.lower(), c_sentence.lower()).ratio()
if ratio > best_ratio:
best_ratio = ratio
best_candidate = c_sentence # Keep original capitalization for output
# Append the candidate if it passes the similarity threshold (e.g., 0.7)
if best_ratio > 0.5 and best_candidate is not None:
votes.append(best_candidate.strip())
# Only consider variants that differ when compared case-insensitively
variant_votes = [vote for vote in votes if vote.lower() != b_sentence.lower()]
if variant_votes:
diff_entry = {
"base": b_sentence,
"variants": Counter(variant_votes),
"vote_count": len(variant_votes),
}
diffs.append(diff_entry)
# Sort diffs by vote_count descending and take only the top max_diffs
diffs.sort(key=lambda d: d["vote_count"], reverse=True)
top_diffs = diffs[:max_diffs]
tests = []
for index, diff in enumerate(top_diffs):
base_sentence = diff["base"]
variant_counter = diff["variants"]
# Print base sentence using fixed-width label formatting
print(f"{'Base:':<{LABEL_WIDTH}} {base_sentence}")
print(f"{'Variants:':<{LABEL_WIDTH}}")
for variant, count in variant_counter.items():
label = f"{count}x:"
print(f"{label:<{LABEL_WIDTH}} {variant}")
# Get the clean version of the sentence
cleaned = clean_base_sentence(base_pdf_file, base_pdf_page, base_sentence)
print(f"{'Clean:':<{LABEL_WIDTH}} {cleaned}")
print("-" * 40)
if cleaned is None:
cleaned = base_sentence
tests.append(
TextPresenceTest(
pdf=os.path.basename(base_pdf_file),
page=base_pdf_page,
id=f"{os.path.basename(base_pdf_file).replace('.pdf', '')}_minediff_{index:02d}",
type="present",
threshold=1.0,
text=cleaned,
)
)
return tests
def get_pdf_from_md(md_path: str) -> str:
base = os.path.basename(md_path)
base = re.sub(r"_\d+\.md$", ".pdf", base)
return os.path.join(os.path.dirname(md_path), "..", "pdfs", base)
def main():
parser = argparse.ArgumentParser(description="Compares sentences from base and candidate texts, printing differences.")
parser.add_argument("--base", default=os.path.join(os.path.dirname(__file__), "chatgpt"), help="Path to the folder containing base .md files.")
parser.add_argument("--compare", default=os.path.join(os.path.dirname(__file__), "olmocr"), help="Path to the folder containing candidate .md files.")
parser.add_argument("--max-diffs", type=int, default=5, help="Maximum number of diffs to display per file.")
parser.add_argument(
"--output", default="mine_diffs_candidates.jsonl", type=str, help="Output of potential candidate test proposals, to be verified or added to dataset"
)
args = parser.parse_args()
base_path = args.base
compare_path = args.compare
max_diffs = args.max_diffs
# Collect all .md files from the base and compare folders
base_files = [f for f in os.listdir(base_path) if f.endswith(".md")]
all_tests = []
# Process each base file and print out the vote differences
for bf in base_files:
base_file_path = os.path.join(base_path, bf)
with open(base_file_path, "r", encoding="utf-8") as f:
base_text = f.read()
compare_files = [f for f in os.listdir(compare_path) if f.endswith(".md") and re.sub(r"_\d+\.md$", "", f) == re.sub(r"_\d+\.md$", "", bf)]
if not compare_files:
print(f"skipping {bf} nothing to compare against")
# Read all candidate texts at once
candidate_texts = []
for cf in compare_files:
with open(os.path.join(compare_path, cf), "r", encoding="utf-8") as f:
candidate_texts.append(f.read())
base_pdf_file = get_pdf_from_md(base_file_path)
base_pdf_page = 1
print(f"Results for base file: {bf}")
tests = compare_votes_for_file(base_pdf_file, base_pdf_page, base_text, candidate_texts, max_diffs)
all_tests.extend(tests)
print("")
# Output test candidates for review after each file, in case there are errors
save_tests(all_tests, args.output)
if __name__ == "__main__":
main()
+305
View File
@@ -0,0 +1,305 @@
#!/usr/bin/env python3
"""
mine_footnotes_gpt.py - Identify PDF documents with footnotes and copy them.
This script:
1. Takes a file containing S3 paths to PDF documents as input
2. For each PDF, renders a random page and uses GPT-4o to check for footnotes
3. Identifies PDFs where the page contains footnotes
4. Copies those PDF files to a new output folder
Usage:
python mine_footnotes_gpt.py --input_list path/to/s3_paths.txt --output_dir path/to/output --api_key your_openai_api_key
"""
import argparse
import os
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, Optional
import boto3
import pypdf
from openai import OpenAI
from pydantic import BaseModel
from tqdm import tqdm
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.filter import PdfFilter
TARGET_IMAGE_DIM = 1024
class Footnote(BaseModel):
marker: str
text_before: Optional[str]
text_after: Optional[str]
class FootnoteDetectionResponse(BaseModel):
"""Structured output for footnote detection."""
footnotes: List[Footnote]
def download_pdf_from_s3(s3_path: str, local_path: str) -> bool:
"""
Download a PDF file from S3.
Args:
s3_path: The S3 path (s3://bucket/path/to/file.pdf)
local_path: The local path to save the file
Returns:
bool: True if download was successful, False otherwise
"""
try:
# Parse S3 path
parts = s3_path.replace("s3://", "").split("/", 1)
bucket = parts[0]
key = parts[1]
# Create S3 client
s3 = boto3.client("s3")
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(local_path), exist_ok=True)
# Download file
s3.download_file(bucket, key, local_path)
return True
except Exception as e:
print(f"Error downloading {s3_path}: {str(e)}")
return False
def check_for_footnotes(pdf_path: str, page_num: int, api_key: str) -> Optional[bool]:
"""
Use GPT-4o to check if a page contains footnotes.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
api_key: OpenAI API key
Returns:
Optional[bool]: True if page contains footnotes, False otherwise, None if detection fails
"""
# Initialize OpenAI client
client = OpenAI(api_key=api_key)
try:
# Render the PDF page as an image (render_pdf_to_base64png is 1-indexed)
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=TARGET_IMAGE_DIM)
# Simple prompt asking about footnotes
prompt = (
"Does this page contain footnotes? Ex. something you'd mark with a <sup> tag in html and is used to indicate some additional detail at the bottom of the page. "
"Do not include references in this determination. The 'marker' is the little bit of text which is in a superscript. Output all the footnotes with their marker, and snippet of text occuring before or after the marker if present."
)
response = client.beta.chat.completions.parse(
model="gpt-5.1",
messages=[
{
"role": "user",
"content": [{"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}}],
}
],
temperature=1.0,
max_completion_tokens=4000,
response_format=FootnoteDetectionResponse,
)
if not response.choices or len(response.choices) == 0:
print(f"No response generated for {pdf_path} page {page_num}")
return None
# Parse the structured response
parsed_response = response.choices[0].message.parsed
if parsed_response is None:
print(f"Failed to parse response for {pdf_path} page {page_num}")
return None
# Check if there are any footnotes in the list
footnotes = parsed_response.footnotes
has_footnotes = len(footnotes) > 0
if has_footnotes:
print(f"Found {len(footnotes)} footnote(s) in {pdf_path} page {page_num + 1}")
return has_footnotes
except Exception as e:
print(f"Error checking {pdf_path} page {page_num}: {str(e)}")
return None
def process_pdf(s3_path: str, temp_dir: str, output_dir: str, api_key: str) -> bool:
"""
Process a single PDF from S3.
Args:
s3_path: S3 path to the PDF
temp_dir: Directory for temporary files
output_dir: Directory for output files
api_key: OpenAI API key
Returns:
bool: True if the PDF has footnotes and was copied, False otherwise
"""
# Extract filename from S3 path
pdf_filename = os.path.basename(s3_path)
local_pdf_path = os.path.join(temp_dir, pdf_filename)
# Download PDF from S3
if not download_pdf_from_s3(s3_path, local_pdf_path):
return False
pdf_filter = PdfFilter()
if pdf_filter.filter_out_pdf(local_pdf_path):
print(f"Filtering out {pdf_filename}")
return False
try:
# Read the PDF to get the number of pages
reader = pypdf.PdfReader(local_pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF {pdf_filename} has no pages")
return False
# Select a random page to check
page_num = random.randint(0, num_pages - 1)
page_num = random.choice([page_num, 0]) # Bias 50% of the time to do the first page
# Check if the page contains footnotes
has_footnotes = check_for_footnotes(local_pdf_path, page_num, api_key)
if has_footnotes:
# Extract just the page with footnotes and save it as a new PDF
os.makedirs(output_dir, exist_ok=True)
# Create output filename with basename_pgnum.pdf format
pdf_basename = os.path.splitext(pdf_filename)[0]
output_pdf_path = os.path.join(output_dir, f"{pdf_basename}_pg{page_num+1}.pdf")
# Extract the single page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[page_num])
# Write the output PDF
with open(output_pdf_path, "wb") as output_file:
writer.write(output_file)
print(f"Extracted page {page_num+1} with footnotes from {pdf_filename} to {os.path.basename(output_pdf_path)}")
return True
return False
except Exception as e:
print(f"Error processing {pdf_filename}: {str(e)}")
return False
finally:
if os.path.exists(local_pdf_path):
os.remove(local_pdf_path)
def main():
parser = argparse.ArgumentParser(description="Identify and copy PDFs with footnotes")
parser.add_argument("--input_list", required=True, help="Path to a file containing S3 paths to PDFs")
parser.add_argument("--output_dir", required=True, help="Directory to copy PDFs with footnotes")
parser.add_argument("--api_key", help="OpenAI API key (if not provided, will use OPENAI_API_KEY environment variable)")
parser.add_argument("--temp_dir", default="/tmp/mine_footnotes", help="Directory for temporary files")
parser.add_argument("--max_pdfs", type=int, default=100, help="Maximum number of PDFs with footnotes to find")
parser.add_argument("--parallel", type=int, default=1, help="Number of parallel workers (default: 1 for sequential)")
parser.add_argument("--reservoir_multiplier", type=int, default=100, help="Multiplier for reservoir sampling (default: 100x max_pdfs)")
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("OPENAI_API_KEY")
if not api_key:
print("Error: OpenAI API key not provided. Use --api_key or set OPENAI_API_KEY environment variable.")
return
os.makedirs(args.temp_dir, exist_ok=True)
os.makedirs(args.output_dir, exist_ok=True)
# Reservoir sampling to get random subset of PDFs
reservoir_size = args.max_pdfs * args.reservoir_multiplier
pdf_paths = []
n = 0 # Total number of items seen
print(f"Using reservoir sampling with size {reservoir_size}")
with open(args.input_list, "r") as f:
for line in tqdm(f):
n += 1
path = line.strip()
if not path:
continue
if len(pdf_paths) < reservoir_size:
pdf_paths.append(path)
else:
# Randomly decide whether to include this item
s = random.randint(1, n)
if s <= reservoir_size:
pdf_paths[s - 1] = path
# Shuffle the reservoir
random.shuffle(pdf_paths)
print(f"Sampled {len(pdf_paths)} PDF paths from {n} total paths")
footnote_pdfs_found = 0
if args.parallel > 1:
# Parallel processing
print(f"Processing PDFs with {args.parallel} parallel workers")
with ThreadPoolExecutor(max_workers=args.parallel) as executor:
futures = []
# Submit all tasks
for s3_path in pdf_paths:
if footnote_pdfs_found >= args.max_pdfs:
break
future = executor.submit(process_pdf, s3_path, args.temp_dir, args.output_dir, api_key)
futures.append(future)
# Process results as they complete
with tqdm(total=min(len(pdf_paths), args.max_pdfs), desc="Processing PDFs") as pbar:
for future in as_completed(futures):
try:
result = future.result()
if result:
footnote_pdfs_found += 1
pbar.update(1)
if footnote_pdfs_found >= args.max_pdfs:
print(f"Reached maximum number of PDFs with footnotes ({args.max_pdfs}), stopping")
# Cancel remaining futures
for f in futures:
f.cancel()
break
except Exception as e:
print(f"Error in parallel processing: {str(e)}")
else:
# Sequential processing
for s3_path in tqdm(pdf_paths, desc="Processing PDFs"):
if process_pdf(s3_path, args.temp_dir, args.output_dir, api_key):
footnote_pdfs_found += 1
if footnote_pdfs_found >= args.max_pdfs:
print(f"Reached maximum number of PDFs with footnotes ({args.max_pdfs}), stopping")
break
print(f"Found and copied {footnote_pdfs_found} PDFs with footnotes to {args.output_dir}")
if __name__ == "__main__":
main()
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""
mine_headers_footers.py - Extract headers and footers from PDF documents.
This script:
1. Takes a file containing S3 paths to PDF documents as input
2. For each PDF, extracts a random page and renders it to an image
3. Uses Gemini to identify headers and footers in the rendered image
4. Creates a test file asserting that the header/footer text should not appear
5. Extracts the page from the PDF and saves it to an output folder
Usage:
python mine_headers_footers.py --input_list path/to/s3_paths.txt --output_dir path/to/output --api_key your_gemini_api_key
"""
import argparse
import base64
import json
import os
import random
from typing import List, Optional
import boto3
import pypdf
from google import genai
from google.genai import types
from tqdm import tqdm
from olmocr.bench.tests import TextPresenceTest, save_tests
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.filter import PdfFilter
def download_pdf_from_s3(s3_path: str, local_path: str) -> bool:
"""
Download a PDF file from S3.
Args:
s3_path: The S3 path (s3://bucket/path/to/file.pdf)
local_path: The local path to save the file
Returns:
bool: True if download was successful, False otherwise
"""
try:
# Parse S3 path
parts = s3_path.replace("s3://", "").split("/", 1)
bucket = parts[0]
key = parts[1]
# Create S3 client
s3 = boto3.client("s3")
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(local_path), exist_ok=True)
# Download file
s3.download_file(bucket, key, local_path)
return True
except Exception as e:
print(f"Error downloading {s3_path}: {str(e)}")
return False
def extract_page_from_pdf(input_path: str, output_path: str, page_num: int) -> bool:
"""
Extract a specific page from a PDF and save it as a new PDF.
Args:
input_path: Path to the input PDF
output_path: Path to save the extracted page
page_num: The page number to extract (0-indexed)
Returns:
bool: True if extraction was successful, False otherwise
"""
try:
# Ensure output directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Read the input PDF
reader = pypdf.PdfReader(input_path)
# Check if page number is valid
if page_num >= len(reader.pages):
print(f"Page number {page_num} out of range for {input_path} with {len(reader.pages)} pages")
return False
# Create a new PDF with just the selected page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[page_num])
# Write the output PDF
with open(output_path, "wb") as output_file:
writer.write(output_file)
return True
except Exception as e:
print(f"Error extracting page {page_num} from {input_path}: {str(e)}")
raise
def detect_headers_footers(pdf_path: str, page_num: int, api_key: str) -> Optional[List[str]]:
"""
Use Gemini to detect headers and footers in a rendered PDF page.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
api_key: Gemini API key
Returns:
Optional[List[str]]: List of detected header/footer texts, or None if detection failed
"""
client = genai.Client(
api_key=os.environ.get("GEMINI_API_KEY"),
)
model = "gemini-2.0-flash"
# Render the PDF page as an image
try:
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=2048) # render_pdf_to_base64png is 1-indexed
except Exception as e:
print(f"Error rendering PDF page: {str(e)}")
return None
image_part = types.Part(inline_data=types.Blob(mime_type="image/png", data=base64.b64decode(image_base64)))
contents = [
types.Content(
role="user",
parts=[
image_part,
types.Part.from_text(
text="""Please extract and display the complete text from the document without omission. Include all sections and ensure nothing is summarized or abbreviated. I want the entire text of the document at any cost. Do not hallucinate"""
),
],
),
]
generate_content_config = types.GenerateContentConfig(
temperature=1,
top_p=0.95,
top_k=40,
max_output_tokens=8192,
response_mime_type="application/json",
response_schema=genai.types.Schema(
type=genai.types.Type.OBJECT,
properties={
"headers": genai.types.Schema(
type=genai.types.Type.ARRAY,
items=genai.types.Schema(
type=genai.types.Type.STRING,
),
),
"footers": genai.types.Schema(
type=genai.types.Type.ARRAY,
items=genai.types.Schema(
type=genai.types.Type.STRING,
),
),
},
),
)
response = client.models.generate_content(model=model, contents=contents, config=generate_content_config)
assert len(response.candidates) > 0, "No candidates found"
assert response.candidates[0].finish_reason == types.FinishReason.STOP, "Finish reason was not STOP, likely a processing error or repetition failure"
data = json.loads(response.candidates[0].content.parts[0].text)
return data.get("headers", []) + data.get("footers", [])
def process_pdf(s3_path: str, temp_dir: str, output_dir: str, api_key: str, tests: List[TextPresenceTest]) -> None:
"""
Process a single PDF from S3.
Args:
s3_path: S3 path to the PDF
temp_dir: Directory for temporary files
output_dir: Directory for output files
api_key: Gemini API key
tests: List to append tests to
"""
# Extract filename from S3 path
pdf_filename = os.path.basename(s3_path)
local_pdf_path = os.path.join(temp_dir, pdf_filename)
# Download PDF from S3
if not download_pdf_from_s3(s3_path, local_pdf_path):
return
pdf_filter = PdfFilter()
if pdf_filter.filter_out_pdf(local_pdf_path):
print("Filtering out", pdf_filename)
return
try:
# Read the PDF to get the number of pages
reader = pypdf.PdfReader(local_pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF {pdf_filename} has no pages")
return
all_pages = list(range(len(reader.pages)))
random.shuffle(all_pages)
for page_num in all_pages:
# Detect headers and footers
header_footer_text = detect_headers_footers(local_pdf_path, page_num, api_key)
# Only stick with headers and footers that have some actual data in them
header_footer_text = [x for x in header_footer_text if len(x.strip()) > 3]
if not header_footer_text:
print(f"No headers/footers detected in {pdf_filename} page {page_num}")
continue
# Extract the page and save to output dir
pdf_basename = os.path.splitext(pdf_filename)[0]
output_pdf_path = os.path.join(output_dir, "pdfs", f"{pdf_basename}_pg{page_num+1}.pdf")
extract_page_from_pdf(local_pdf_path, output_pdf_path, page_num)
# TODO Now, process it again to make sure extracted headers/footers don't appear in the main body of the text
# Create tests for each header/footer text
for i, text in enumerate(header_footer_text):
test_id = f"{pdf_basename}_pg{page_num+1}_header_{i:02d}"
test = TextPresenceTest(
id=test_id,
pdf=f"{pdf_basename}_pg{page_num+1}.pdf",
page=1, # The extracted PDF has only one page
type="absent",
text=text,
max_diffs=0,
)
tests.append(test)
print(f"Processed {pdf_filename} page {page_num+1}, found {len(header_footer_text)} headers/footers")
return
except Exception as e:
print(f"Error processing {pdf_filename}: {str(e)}")
finally:
# Clean up temporary file
if os.path.exists(local_pdf_path):
os.remove(local_pdf_path)
def main():
parser = argparse.ArgumentParser(description="Extract headers and footers from PDF documents")
parser.add_argument("--input_list", required=True, help="Path to a file containing S3 paths to PDFs")
parser.add_argument("--output_dir", required=True, help="Directory to store extracted pages and tests")
parser.add_argument("--api_key", help="Gemini API key (if not provided, will use GEMINI_API_KEY environment variable)")
parser.add_argument("--temp_dir", default="/tmp/mine_headers_footers", help="Directory for temporary files")
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("GEMINI_API_KEY")
if not api_key:
print("Error: Gemini API key not provided. Use --api_key or set GEMINI_API_KEY environment variable.")
return
# Create directories
os.makedirs(args.temp_dir, exist_ok=True)
os.makedirs(os.path.join(args.output_dir, "pdfs"), exist_ok=True)
# Read input list
with open(args.input_list, "r") as f:
s3_paths = [line.strip() for line in f if line.strip()]
print(f"Found {len(s3_paths)} PDF paths in input list")
# Process each PDF
tests = []
for s3_path in tqdm(s3_paths, desc="Processing PDFs"):
process_pdf(s3_path, args.temp_dir, args.output_dir, api_key, tests)
# Save tests after each PDF to avoid losing data in case of crashes
if tests:
save_tests(tests, os.path.join(args.output_dir, "header_footer_tests.jsonl"))
if len(tests) > 100:
break
print(f"Saved {len(tests)} tests to {os.path.join(args.output_dir, 'header_footer_tests.jsonl')}")
if __name__ == "__main__":
main()
@@ -0,0 +1,341 @@
#!/usr/bin/env python3
"""
mine_length_gpt_simple.py - Identify PDF documents by word count and copy them.
This script:
1. Takes a file containing S3 paths to PDF documents as input
2. For each PDF, renders a random page and uses GPT to analyze document structure
3. Identifies document elements and estimates word counts
4. Copies those PDF pages to folders organized by total word count
Usage:
python mine_length_gpt_simple.py --input_list path/to/s3_paths.txt --output_dir path/to/output --api_key your_openai_api_key
"""
import argparse
import os
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
import boto3
import pypdf
from openai import OpenAI
from pydantic import BaseModel
from tqdm import tqdm
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.filter import PdfFilter
TARGET_IMAGE_DIM = 1024
class StructureElement(BaseModel):
"""Information about a single document element."""
type: str # One of: paragraph, heading, footer, table, equation, image
estimated_words: int
class DocumentStructure(BaseModel):
"""Structured output for document structure analysis."""
elements: list[StructureElement]
def download_pdf_from_s3(s3_path: str, local_path: str) -> bool:
"""
Download a PDF file from S3.
Args:
s3_path: The S3 path (s3://bucket/path/to/file.pdf)
local_path: The local path to save the file
Returns:
bool: True if download was successful, False otherwise
"""
try:
# Parse S3 path
parts = s3_path.replace("s3://", "").split("/", 1)
bucket = parts[0]
key = parts[1]
# Create S3 client
s3 = boto3.client("s3")
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(local_path), exist_ok=True)
# Download file
s3.download_file(bucket, key, local_path)
return True
except Exception as e:
print(f"Error downloading {s3_path}: {str(e)}")
return False
def get_word_count_bucket(total_words: int) -> str:
"""
Get the folder name for a given word count, bucketed by powers of 2.
Args:
total_words: Total number of words across all elements
Returns:
str: Folder name like "0_words", "1_word", "2_words", "4_words", etc.
"""
if total_words == 0:
return "0_words"
elif total_words == 1:
return "1_word"
else:
# Find the next power of 2 >= total_words
power = 1
while power < total_words:
power *= 2
return f"{power}_words"
def analyze_document_structure(pdf_path: str, page_num: int, api_key: str) -> Optional[int]:
"""
Use GPT to analyze document structure and estimate word count.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
api_key: OpenAI API key
Returns:
Optional[int]: Total estimated word count, or None if analysis fails
"""
# Initialize OpenAI client
client = OpenAI(api_key=api_key)
try:
# Render the PDF page as an image (render_pdf_to_base64png is 1-indexed)
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=TARGET_IMAGE_DIM)
# Prompt asking for detailed document structure
prompt = """Analyze the structure of this document page. Identify all distinct elements on the page and classify each one.
For each element, specify:
- type: One of the following: paragraph, heading, footer, table, equation, image
- estimated_words: Your best estimate of the number of words of text in that element
Be thorough and identify all visible elements."""
response = client.beta.chat.completions.parse(
model="gpt-5.1",
messages=[
{
"role": "user",
"content": [{"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}}],
}
],
max_completion_tokens=2000,
response_format=DocumentStructure,
)
if not response.choices or len(response.choices) == 0:
print(f"No response generated for {pdf_path} page {page_num}")
return None
# Parse the structured response
parsed_response = response.choices[0].message.parsed
if parsed_response is None:
print(f"Failed to parse response for {pdf_path} page {page_num}")
return None
elements = parsed_response.elements
total_words = sum(element.estimated_words for element in elements)
print(f"Found {len(elements)} element(s) in {pdf_path} page {page_num + 1}, total words: {total_words}")
# Group elements by type and show summary
element_summary = {}
for element in elements:
if element.type not in element_summary:
element_summary[element.type] = {"count": 0, "words": 0}
element_summary[element.type]["count"] += 1
element_summary[element.type]["words"] += element.estimated_words
for element_type, stats in sorted(element_summary.items()):
print(f" {element_type}: {stats['count']} element(s), {stats['words']} words")
return total_words
except Exception as e:
print(f"Error analyzing {pdf_path} page {page_num}: {str(e)}")
return None
def process_pdf(s3_path: str, temp_dir: str, output_dir: str, api_key: str) -> bool:
"""
Process a single PDF from S3.
Args:
s3_path: S3 path to the PDF
temp_dir: Directory for temporary files
output_dir: Directory for output files
api_key: OpenAI API key
Returns:
bool: True if the PDF was analyzed and copied, False otherwise
"""
# Extract filename from S3 path
pdf_filename = os.path.basename(s3_path)
local_pdf_path = os.path.join(temp_dir, pdf_filename)
# Download PDF from S3
if not download_pdf_from_s3(s3_path, local_pdf_path):
return False
pdf_filter = PdfFilter()
if pdf_filter.filter_out_pdf(local_pdf_path):
print(f"Filtering out {pdf_filename}")
return False
try:
# Read the PDF to get the number of pages
reader = pypdf.PdfReader(local_pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF {pdf_filename} has no pages")
return False
# Select a random page to check
page_num = random.randint(0, num_pages - 1)
page_num = random.choice([page_num, 0]) # Bias 50% of the time to do the first page
# Analyze document structure
total_words = analyze_document_structure(local_pdf_path, page_num, api_key)
if total_words is None:
return False
# Get the word count bucket for organizing output
bucket_name = get_word_count_bucket(total_words)
bucket_dir = os.path.join(output_dir, bucket_name)
os.makedirs(bucket_dir, exist_ok=True)
# Create output filename with basename_pgnum.pdf format
pdf_basename = os.path.splitext(pdf_filename)[0]
output_pdf_path = os.path.join(bucket_dir, f"{pdf_basename}_pg{page_num+1}.pdf")
# Extract the single page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[page_num])
# Write the output PDF
with open(output_pdf_path, "wb") as output_file:
writer.write(output_file)
print(f"Extracted page {page_num+1} from {pdf_filename} to {bucket_name}/{os.path.basename(output_pdf_path)}")
return True
except Exception as e:
print(f"Error processing {pdf_filename}: {str(e)}")
return False
finally:
if os.path.exists(local_pdf_path):
os.remove(local_pdf_path)
def main():
parser = argparse.ArgumentParser(description="Identify and copy PDFs by word count")
parser.add_argument("--input_list", required=True, help="Path to a file containing S3 paths to PDFs")
parser.add_argument("--output_dir", required=True, help="Directory to copy PDF pages")
parser.add_argument("--api_key", help="OpenAI API key (if not provided, will use OPENAI_API_KEY environment variable)")
parser.add_argument("--temp_dir", default="/tmp/mine_length", help="Directory for temporary files")
parser.add_argument("--max_pdfs", type=int, default=100, help="Maximum number of PDFs to process")
parser.add_argument("--parallel", type=int, default=1, help="Number of parallel workers (default: 1 for sequential)")
parser.add_argument("--reservoir_multiplier", type=int, default=100, help="Multiplier for reservoir sampling (default: 100x max_pdfs)")
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("OPENAI_API_KEY")
if not api_key:
print("Error: OpenAI API key not provided. Use --api_key or set OPENAI_API_KEY environment variable.")
return
os.makedirs(args.temp_dir, exist_ok=True)
os.makedirs(args.output_dir, exist_ok=True)
# Reservoir sampling to get random subset of PDFs
reservoir_size = args.max_pdfs * args.reservoir_multiplier
pdf_paths = []
n = 0 # Total number of items seen
print(f"Using reservoir sampling with size {reservoir_size}")
with open(args.input_list, "r") as f:
for line in tqdm(f):
n += 1
path = line.strip()
if not path:
continue
if len(pdf_paths) < reservoir_size:
pdf_paths.append(path)
else:
# Randomly decide whether to include this item
s = random.randint(1, n)
if s <= reservoir_size:
pdf_paths[s - 1] = path
# Shuffle the reservoir
random.shuffle(pdf_paths)
print(f"Sampled {len(pdf_paths)} PDF paths from {n} total paths")
pdfs_processed = 0
if args.parallel > 1:
# Parallel processing
print(f"Processing PDFs with {args.parallel} parallel workers")
with ThreadPoolExecutor(max_workers=args.parallel) as executor:
futures = []
# Submit all tasks
for s3_path in pdf_paths:
if pdfs_processed >= args.max_pdfs:
break
future = executor.submit(process_pdf, s3_path, args.temp_dir, args.output_dir, api_key)
futures.append(future)
# Process results as they complete
with tqdm(total=min(len(pdf_paths), args.max_pdfs), desc="Processing PDFs") as pbar:
for future in as_completed(futures):
try:
result = future.result()
if result:
pdfs_processed += 1
pbar.update(1)
if pdfs_processed >= args.max_pdfs:
print(f"Reached maximum number of PDFs ({args.max_pdfs}), stopping")
# Cancel remaining futures
for f in futures:
f.cancel()
break
except Exception as e:
print(f"Error in parallel processing: {str(e)}")
else:
# Sequential processing
for s3_path in tqdm(pdf_paths, desc="Processing PDFs"):
if process_pdf(s3_path, args.temp_dir, args.output_dir, api_key):
pdfs_processed += 1
if pdfs_processed >= args.max_pdfs:
print(f"Reached maximum number of PDFs ({args.max_pdfs}), stopping")
break
print(f"Processed and copied {pdfs_processed} PDF pages to {args.output_dir}")
if __name__ == "__main__":
main()
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""
mine_long_tiny_text.py - Extract long text from PDF documents.
This script:
1. Takes a folder containing PDF documents as input
2. For each PDF, extracts random pages and renders them to images
3. Uses Gemini to identify text in the rendered images
4. Creates test files asserting that the text should be present
5. Extracts the pages from the PDF and saves them to an output folder
Usage:
python mine_long_tiny_text.py --input_dir path/to/pdf_folder --output_dir path/to/output --api_key your_gemini_api_key
"""
import argparse
import base64
import json
import os
import random
from typing import List
import pypdf
from google import genai
from google.genai import types
from tqdm import tqdm
from olmocr.bench.tests import TextPresenceTest, save_tests
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.filter import PdfFilter
def extract_page_from_pdf(input_path: str, output_path: str, page_num: int) -> bool:
"""
Extract a specific page from a PDF and save it as a new PDF.
Args:
input_path: Path to the input PDF
output_path: Path to save the extracted page
page_num: The page number to extract (0-indexed)
Returns:
bool: True if extraction was successful, False otherwise
"""
try:
# Ensure output directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Read the input PDF
reader = pypdf.PdfReader(input_path)
# Check if page number is valid
if page_num >= len(reader.pages):
print(f"Page number {page_num} out of range for {input_path} with {len(reader.pages)} pages")
return False
# Create a new PDF with just the selected page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[page_num])
# Write the output PDF
with open(output_path, "wb") as output_file:
writer.write(output_file)
return True
except Exception as e:
print(f"Error extracting page {page_num} from {input_path}: {str(e)}")
return False # Return False instead of raising to continue processing other PDFs
def detect_long_text(pdf_path: str, page_num: int, api_key: str) -> List[str]:
"""
Use Gemini to detect long text in a rendered PDF page.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
api_key: Gemini API key
Returns:
List[str]: List of detected text, empty list if detection failed
"""
try:
client = genai.Client(
api_key=api_key, # Use the provided API key instead of environment variable
)
model = "gemini-2.0-flash"
# Render the PDF page as an image
try:
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=2048) # render_pdf_to_base64png is 1-indexed
except Exception as e:
print(f"Error rendering PDF page {page_num+1} from {pdf_path}: {str(e)}")
return []
image_part = types.Part(inline_data=types.Blob(mime_type="image/png", data=base64.b64decode(image_base64)))
contents = [
types.Content(
role="user",
parts=[
image_part,
types.Part.from_text(
text="""Extract and display all text from the document without any omissions. The documents may be in a multi-column format, so handle that carefully. If the words are less than 9, combine text from the next line. I don't want all the text, just randomly pick few sentences. Do not summarize, abbreviate, or hallucinate any content."""
),
],
),
]
generate_content_config = types.GenerateContentConfig(
temperature=1,
top_p=0.95,
top_k=40,
max_output_tokens=8192,
response_mime_type="application/json",
response_schema=genai.types.Schema(
type=genai.types.Type.OBJECT,
properties={
"equations": genai.types.Schema(
type=genai.types.Type.ARRAY,
items=genai.types.Schema(
type=genai.types.Type.STRING,
),
),
},
),
)
response = client.models.generate_content(model=model, contents=contents, config=generate_content_config)
if len(response.candidates) == 0:
print(f"No candidates found for {pdf_path} page {page_num+1}")
return []
if response.candidates[0].finish_reason != types.FinishReason.STOP:
print(f"Finish reason was not STOP for {pdf_path} page {page_num+1}, likely a processing error")
return []
try:
data = json.loads(response.candidates[0].content.parts[0].text)
return data.get("equations", [])
except json.JSONDecodeError:
print(f"Failed to parse JSON response for {pdf_path} page {page_num+1}")
return []
except Exception as e:
print(f"Error detecting text in {pdf_path} page {page_num+1}: {str(e)}")
return []
def process_pdf(
pdf_path: str, output_dir: str, api_key: str, tests: List[TextPresenceTest], max_pages_per_pdf: int = 20, force_processing: bool = True
) -> bool:
"""
Process a single PDF, extracting text from multiple pages.
Args:
pdf_path: Path to the PDF
output_dir: Directory for output files
api_key: Gemini API key
tests: List to append tests to
max_pages_per_pdf: Maximum number of pages to process per PDF
force_processing: If True, process PDF even if it would normally be filtered out
Returns:
bool: True if PDF was processed successfully
"""
# Extract filename from path
pdf_filename = os.path.basename(pdf_path)
pdf_filter = PdfFilter()
if not force_processing and pdf_filter.filter_out_pdf(pdf_path):
print(f"Filtering out {pdf_filename} (use --force_processing to override)")
return False
try:
# Read the PDF to get the number of pages
reader = pypdf.PdfReader(pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF {pdf_filename} has no pages")
return False
# Get all pages and shuffle them to select a random subset
all_pages = list(range(num_pages))
random.shuffle(all_pages)
# Take only the specified maximum number of pages
pages_to_process = all_pages[: min(max_pages_per_pdf, num_pages)]
processed_pages = 0
pdf_processed = False # Flag to track if at least one page was processed
for page_num in pages_to_process:
# Detect text
text_sections = detect_long_text(pdf_path, page_num, api_key)
# Only keep text sections that are non-empty
text_sections = [text for text in text_sections if len(text.strip()) > 3]
# Extract the page regardless of text detection
pdf_basename = os.path.splitext(pdf_filename)[0]
output_pdf_path = os.path.join(output_dir, "pdfs", f"{pdf_basename}_pg{page_num+1}.pdf")
# Extract the page regardless of text detection
page_extracted = extract_page_from_pdf(pdf_path, output_pdf_path, page_num)
if page_extracted:
pdf_processed = True
if not text_sections:
print(f"No text detected in {pdf_filename} page {page_num+1} - creating empty test")
# Create a placeholder test to ensure the PDF is represented
test_id = f"{pdf_basename}_pg{page_num+1}_no_text"
test = TextPresenceTest(
id=test_id,
pdf=f"{pdf_basename}_pg{page_num+1}.pdf",
page=1, # The extracted PDF has only one page
type="present",
text="No text detected", # Placeholder text
max_diffs=0,
)
tests.append(test)
else:
# Create tests for each text section
for i, text_section in enumerate(text_sections):
test_id = f"{pdf_basename}_pg{page_num+1}_text_{i:02d}"
test = TextPresenceTest(
id=test_id,
pdf=f"{pdf_basename}_pg{page_num+1}.pdf",
page=1, # The extracted PDF has only one page
type="present",
text=text_section,
max_diffs=0,
)
tests.append(test)
print(f"Processed {pdf_filename} page {page_num+1}, found {len(text_sections)} text sections")
processed_pages += 1
print(f"Completed processing {processed_pages} pages from {pdf_filename}")
return pdf_processed
except Exception as e:
print(f"Error processing {pdf_filename}: {str(e)}")
return False
def get_pdf_files_from_directory(directory: str) -> List[str]:
"""
Get a list of all PDF files in a directory.
Args:
directory: Path to the directory containing PDFs
Returns:
List[str]: List of full paths to PDF files
"""
pdf_files = []
for filename in os.listdir(directory):
if filename.lower().endswith(".pdf"):
full_path = os.path.join(directory, filename)
if os.path.isfile(full_path):
pdf_files.append(full_path)
return pdf_files
def main():
parser = argparse.ArgumentParser(description="Extract long text from PDF documents")
parser.add_argument("--input_dir", required=True, help="Directory containing PDF files")
parser.add_argument("--output_dir", required=True, help="Directory to store extracted pages and tests")
parser.add_argument("--api_key", help="Gemini API key (if not provided, will use GEMINI_API_KEY environment variable)")
parser.add_argument("--force_processing", action="store_true", help="Process all PDFs even if they would normally be filtered out")
parser.add_argument("--max_pages_per_pdf", type=int, default=20, help="Maximum number of pages to process per PDF")
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("GEMINI_API_KEY")
if not api_key:
print("Error: Gemini API key not provided. Use --api_key or set GEMINI_API_KEY environment variable.")
return
# Create directories
os.makedirs(os.path.join(args.output_dir, "pdfs"), exist_ok=True)
# Get PDF files from input directory
pdf_files = get_pdf_files_from_directory(args.input_dir)
if not pdf_files:
print(f"No PDF files found in {args.input_dir}")
return
print(f"Found {len(pdf_files)} PDF files in input directory")
# Process each PDF
tests = []
processed_pdfs = 0
for pdf_path in tqdm(pdf_files, desc="Processing PDFs"):
if process_pdf(pdf_path, args.output_dir, api_key, tests, args.max_pages_per_pdf, args.force_processing):
processed_pdfs += 1
# Save tests after each PDF to avoid losing data in case of crashes
if tests:
save_tests(tests, os.path.join(args.output_dir, "long_tests.jsonl"))
print(f"Successfully processed {processed_pdfs} out of {len(pdf_files)} PDFs")
print(f"Saved {len(tests)} tests to {os.path.join(args.output_dir, 'long_tests.jsonl')}")
if __name__ == "__main__":
main()
+373
View File
@@ -0,0 +1,373 @@
#!/usr/bin/env python3
"""
mine_math.py - Extract and validate math equations from candidate files and TeX bases.
This upgraded version:
• Uses the Python logging module for cleaner logging.
• Uses tqdm to display a progress bar.
• Uses ProcessPoolExecutor to process TeX file groups in parallel.
• For each TeX file, shuffles its pages randomly and processes them one-by-one.
Once three pages return at least one equation each, further pages are skipped.
• Adds an argparse argument for the similarity threshold for matches.
• Saves JSONL outputs incrementally as each TeX file group is processed.
Usage:
python mine_math.py --math_data /path/to/math_data --candidate candidate_folder --output_file math_tests.jsonl
[--max_pages 3] [--parallel 8] [--sim_threshold 0.7]
"""
import argparse
import glob
import logging
import os
import random
import re
from concurrent.futures import ProcessPoolExecutor, as_completed
from typing import Dict, List, Optional, Tuple
import numba
import numpy as np
from tqdm import tqdm
from olmocr.bench.katex.render import render_equation
from olmocr.bench.tests import (
MathTest, # Assumes MathTest is JSON serializable or has __dict__
)
from olmocr.bench.tests import (
save_tests, # Original saving function (not used for incremental save)
)
# --- Logging Setup ---
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# --- Utility Functions ---
def normalize_text(text: str) -> str:
"""Normalize text for better matching."""
text = re.sub(r"\s+", " ", text)
replacements = {"'": "'", "": "'", '"': '"', "": '"', "_": "_", "": "-", "": "-", "": "-", "": "-"}
for fancy_char, ascii_char in replacements.items():
text = text.replace(fancy_char, ascii_char)
return text
def extract_tex_content(tex_file: str) -> str:
"""Extract the content from a TeX file."""
try:
with open(tex_file, "r", encoding="utf-8") as f:
return f.read()
except UnicodeDecodeError:
try:
with open(tex_file, "r", encoding="latin-1") as f:
return f.read()
except Exception as e:
logging.error("Error reading %s: %s", tex_file, e)
return ""
def extract_candidate_content(candidate_file: str) -> str:
"""Extract the content from a candidate .md file."""
try:
with open(candidate_file, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
logging.error("Error reading %s: %s", candidate_file, e)
return ""
def extract_math_from_tex(tex_content: str) -> List[Tuple[str, str]]:
"""
Extract math equations from TeX content.
Returns list of tuples (equation_type, equation_content)
"""
math_equations = []
# Patterns for display math
display_patterns = [
(r"\$\$(.*?)\$\$", "$$"),
(r"\\begin\{equation\}(.*?)\\end\{equation\}", "equation"),
(r"\\begin\{equation\*\}(.*?)\\end\{equation\*\}", "equation*"),
(r"\\begin\{align\}(.*?)\\end\{align\}", "align"),
(r"\\begin\{align\*\}(.*?)\\end\{align\*\}", "align*"),
(r"\\begin\{displaymath\}(.*?)\\end\{displaymath\}", "displaymath"),
(r"\\\[(.*?)\\\]", "displaymath"),
]
# Patterns for inline math
inline_patterns = [(r"\$(.*?)\$", "inline"), (r"\\\((.*?)\\\)", "inline")]
for pattern_list in [display_patterns, inline_patterns]:
for pattern, eq_type in pattern_list:
matches = re.finditer(pattern, tex_content, re.DOTALL)
for match in matches:
equation = match.group(1).strip()
if equation and not equation.isspace():
math_equations.append((eq_type, equation))
return math_equations
@numba.njit
def compute_dp(candidate_arr, text_arr):
m = candidate_arr.shape[0]
n = text_arr.shape[0]
dp = np.empty((m + 1, n + 1), dtype=np.int32)
# For empty candidate, cost is 0 (can match anywhere in text)
for j in range(n + 1):
dp[0, j] = 0
# When text is empty, need to delete all candidate characters.
for i in range(1, m + 1):
dp[i, 0] = i
for i in range(1, m + 1):
for j in range(1, n + 1):
cost = 0 if candidate_arr[i - 1] == text_arr[j - 1] else 1
dp[i, j] = min(
dp[i - 1, j - 1] + cost, dp[i - 1, j] + 1, dp[i, j - 1] + 1 # substitution or match # deletion (from candidate)
) # insertion (in candidate)
return dp
@numba.njit
def find_best_end(dp, m, n):
best_distance = 1 << 30 # a large number
best_end = 0
for j in range(n + 1):
if dp[m, j] < best_distance:
best_distance = dp[m, j]
best_end = j
return best_end, best_distance
@numba.njit
def backtrack(dp, candidate_arr, text_arr, m, best_end):
i = m
j = best_end
while i > 0:
# Check for a diagonal move (match or substitution)
if j > 0 and dp[i, j] == dp[i - 1, j - 1] + (0 if candidate_arr[i - 1] == text_arr[j - 1] else 1):
i -= 1
j -= 1
elif dp[i, j] == dp[i - 1, j] + 1:
i -= 1
else:
j -= 1
return j # start index in text
def find_matching_content(candidate_text: str, tex_content: str, sim_threshold: float) -> Optional[str]:
"""
Find the substring of tex_content that most closely matches candidate_text using
dynamic programming accelerated by numba. Returns the matching substring if its
normalized similarity (1 - (edit_distance / len(candidate_text))) is above sim_threshold,
otherwise returns None.
"""
candidate_norm = normalize_text(candidate_text)
tex_norm = normalize_text(tex_content)
m = len(candidate_norm)
n = len(tex_norm)
if m == 0 or n == 0:
return None
# Convert strings to numpy arrays of integer character codes.
candidate_arr = np.empty(m, dtype=np.int32)
for i, c in enumerate(candidate_norm):
candidate_arr[i] = ord(c)
text_arr = np.empty(n, dtype=np.int32)
for j, c in enumerate(tex_norm):
text_arr[j] = ord(c)
dp = compute_dp(candidate_arr, text_arr)
best_end, min_distance = find_best_end(dp, m, n)
similarity = (m - min_distance) / m
logging.info("Similarity: %.3f", similarity)
if similarity < sim_threshold:
return None
start_index = backtrack(dp, candidate_arr, text_arr, m, best_end)
return tex_norm[start_index:best_end]
def parse_candidate_filename(filename: str) -> Optional[Tuple[str, int]]:
"""
Parse candidate filename in the format: [tex file basename]_pg[pagenum]_repeat1.md
Returns tuple (tex_basename, page_num) or None if the format doesn't match.
"""
basename = os.path.basename(filename)
match = re.match(r"(.+)_pg(\d+)_repeat\d+\.md$", basename)
if match:
tex_basename = match.group(1)
page_num = int(match.group(2))
return tex_basename, page_num
return None
def validate_equation(equation: str) -> bool:
"""
Validate that an equation renders correctly with KaTeX.
Returns True if the equation is valid, False otherwise.
"""
rendered = render_equation(equation)
return rendered is not None
def process_candidate_file(candidate_file: str, pdfs_folder: str, sim_threshold: float) -> List[MathTest]:
"""
Process a single candidate file.
Returns a list of MathTest objects extracted from the corresponding TeX file.
"""
logging.info("Processing %s", candidate_file)
tests = []
parse_result = parse_candidate_filename(candidate_file)
if not parse_result:
logging.error("Filename %s does not match expected format.", candidate_file)
return tests
tex_basename, page_num = parse_result
tex_file_path = os.path.join(pdfs_folder, f"{tex_basename}.tex")
if not os.path.exists(tex_file_path):
logging.error("TeX file %s not found for candidate %s.", tex_file_path, candidate_file)
return tests
candidate_text = extract_candidate_content(candidate_file)
tex_content = extract_tex_content(tex_file_path)
if not tex_content or not candidate_text or len(tex_content.strip()) < 100 or len(candidate_text.strip()) < 100:
logging.error("No content extracted from %s", tex_file_path)
return tests
matching_tex = find_matching_content(candidate_text, tex_content, sim_threshold)
if not matching_tex:
logging.warning("No matching TeX content found in %s for candidate %s", tex_file_path, candidate_file)
return tests
logging.debug("Matching TeX content: %s", matching_tex)
math_equations = extract_math_from_tex(matching_tex)
if not math_equations:
logging.warning("No math equations found in matching content for candidate %s", candidate_file)
return tests
# Filter out equations that are too short, remove duplicates, and shuffle
math_equations = [(eq_type, eq.strip()) for (eq_type, eq) in math_equations if len(eq.strip()) > 20]
math_equations = list(set(math_equations))
random.shuffle(math_equations)
for i, (eq_type, equation) in enumerate(math_equations):
if validate_equation(equation):
test_id = f"{tex_basename}_pg{page_num}_math_{i:03d}"
math_test = MathTest(
id=test_id,
pdf=f"{tex_basename}.pdf",
page=page_num,
type="math",
math=equation,
)
tests.append(math_test)
if len(tests) >= 10:
break
return tests
def process_tex_file_group(tex_basename: str, candidate_files: List[str], pdfs_folder: str, sim_threshold: float, max_pages: int) -> List[MathTest]:
"""
For a given TeX file, group candidate files by page, randomly shuffle the pages,
and process them one-by-one. Stop once max_pages (pages with valid equations) have
been processed.
"""
tests = []
valid_pages = set()
# Group candidate files by page number.
page_dict: Dict[int, List[str]] = {}
for candidate_file in candidate_files:
parse_result = parse_candidate_filename(candidate_file)
if not parse_result:
continue
_, page_num = parse_result
page_dict.setdefault(page_num, []).append(candidate_file)
# For each page, randomly choose one candidate file.
distinct_candidate_files = []
for page_num, files in page_dict.items():
chosen_file = random.choice(files)
distinct_candidate_files.append(chosen_file)
# Shuffle the pages randomly.
random.shuffle(distinct_candidate_files)
# Process pages sequentially until max_pages with valid equations have been found.
for candidate_file in distinct_candidate_files:
result = process_candidate_file(candidate_file, pdfs_folder, sim_threshold)
if result:
tests.extend(result)
# Mark this page as valid.
page_num = parse_candidate_filename(candidate_file)[1]
valid_pages.add(page_num)
if len(valid_pages) >= max_pages:
break
return tests
def main():
parser = argparse.ArgumentParser(description="Extract math equations from candidate files and corresponding TeX bases.")
parser.add_argument("--math_data", required=True, help="Path to math_data folder")
parser.add_argument("--candidate", required=True, help="Candidate folder name inside math_data")
parser.add_argument("--max_pages", type=int, default=1, help="Maximum distinct pages with equations to process per TeX document")
parser.add_argument("--parallel", type=int, default=8, help="Maximum process pool workers")
parser.add_argument("--sim_threshold", type=float, default=0.7, help="Similarity threshold for matching candidate text")
args = parser.parse_args()
candidate_folder = os.path.join(args.math_data, args.candidate)
pdfs_folder = os.path.join(args.math_data, "pdfs")
candidate_files = glob.glob(os.path.join(candidate_folder, "*.md"))
logging.info("Found %d candidate files.", len(candidate_files))
# Group candidate files by TeX basename.
tex_groups: Dict[str, List[str]] = {}
for candidate_file in candidate_files:
parse_result = parse_candidate_filename(candidate_file)
if not parse_result:
continue
tex_basename, _ = parse_result
tex_groups.setdefault(tex_basename, []).append(candidate_file)
logging.info("Found %d TeX groups.", len(tex_groups))
# Remove output file if it exists to start fresh
output_file = os.path.join(args.math_data, "math_tests.jsonl")
if os.path.exists(output_file):
os.remove(output_file)
all_math_tests = []
# Process each TeX group in parallel using ProcessPoolExecutor.
with ProcessPoolExecutor(max_workers=args.parallel) as executor:
future_to_tex = {
executor.submit(process_tex_file_group, tex_basename, candidate_list, pdfs_folder, args.sim_threshold, args.max_pages): tex_basename
for tex_basename, candidate_list in tex_groups.items()
}
for future in tqdm(as_completed(future_to_tex), total=len(future_to_tex), desc="Processing TeX files"):
tex_basename = future_to_tex[future]
try:
tests = future.result()
all_math_tests.extend(tests)
# Incrementally save tests as each TeX group finishes processing.
save_tests(all_math_tests, output_file)
except Exception as e:
logging.error("Error processing TeX group %s: %s", tex_basename, e)
logging.info("Found %d valid math equations from %d TeX groups.", len(all_math_tests), len(tex_groups))
logging.info("Results incrementally saved to %s", output_file)
if __name__ == "__main__":
main()
+405
View File
@@ -0,0 +1,405 @@
"""
mine_multi_column.py - Extract text from PDF documents which has multiple columns.
This script:
1. Takes a file containing folder path which contains PDF documents as input
2. Process each PDF to generate an HTML representation
3. For each PDF, it renders to an image
4. Uses Claude Sonnet to identify text from multiple columns in the rendered image
5. Creates a test file asserting that the order (before/after) of text should appear
6. Extracts the page from the PDF and saves it to an output folder
Usage:
python mine_headers_footers.py --input_dir path/to/pdfs --output_dir path/to/output --api_key your_anthropic_api_key
"""
import argparse
import asyncio
import concurrent.futures
import json
import os
import random
import re
import subprocess
import uuid
from concurrent.futures import ThreadPoolExecutor
from typing import Dict, List
import pypdf
from anthropic import Anthropic
from bs4 import BeautifulSoup
from playwright.async_api import async_playwright
from syntok.segmenter import process
from tqdm import tqdm
from olmocr.data.renderpdf import (
get_png_dimensions_from_base64,
render_pdf_to_base64png,
)
def extract_code_block(initial_response):
html_blocks = re.findall(r"```html\n(.*?)```", initial_response, re.DOTALL)
if html_blocks:
return html_blocks[-1].strip()
code_blocks = re.findall(r"```\n(.*?)```", initial_response, re.DOTALL)
if code_blocks:
return code_blocks[-1].strip()
html_blocks_no_newline = re.findall(r"```html(.*?)```", initial_response, re.DOTALL)
if html_blocks_no_newline:
return html_blocks_no_newline[-1].strip()
code_blocks_no_newline = re.findall(r"```(.*?)```", initial_response, re.DOTALL)
if code_blocks_no_newline:
return code_blocks_no_newline[-1].strip()
return None
def generate_html_from_image(client, image_base64):
"""Call Claude API to generate HTML from an image using a multi-step prompting strategy."""
png_width, png_height = get_png_dimensions_from_base64(image_base64)
try:
analysis_response = client.messages.create(
model="claude-3-7-sonnet-20250219",
max_tokens=2000,
temperature=0.1,
messages=[
{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_base64}},
{
"type": "text",
"text": (
"Analyze this document and provide a detailed assessment of its structure. "
"Focus on the layout, headings, footers, and any complex formatting. Please be precise."
),
},
],
}
],
)
analysis_text = ""
for content in analysis_response.content:
if content.type == "text":
analysis_text += content.text
initial_response = client.messages.create(
model="claude-3-7-sonnet-20250219",
max_tokens=6000,
temperature=0.2,
messages=[
{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_base64}},
{
"type": "text",
"text": (
"Render this document as clean, semantic HTML. Here is the analysis of the document structure:\n\n"
f"{analysis_text}\n\n"
"Requirements:\n"
"1. Use appropriate HTML tags for headings, paragraphs, and lists.\n"
"2. Use <header> and <footer> for top and bottom content.\n"
"3. For images, use a placeholder <div> with class 'image'.\n"
"4. Render math equations inline using \\( \\) or \\[ \\].\n"
"5. Preserve any multi-column layout using CSS flexbox or grid.\n"
f"6. The viewport is fixed at {png_width // 2}x{png_height // 2} pixels.\n\n"
"Enclose your HTML in a ```html code block."
),
},
],
}
],
)
initial_html = ""
for content in initial_response.content:
if content.type == "text":
initial_html += content.text
return extract_code_block(initial_html)
except Exception as e:
print(f"Error calling Claude API: {e}")
return None
def generate_tests_from_html(html_content: str, pdf_id: str, page_num: int) -> List[Dict]:
"""
Generate order tests from HTML content by splitting the main text into sentences and pairing them.
Only order tests are generated.
"""
tests = []
pdf_filename = pdf_id
soup = BeautifulSoup(html_content, "html.parser")
full_text = soup.get_text(separator=" ").strip()
sentences = []
for paragraph in process(full_text):
for sentence in paragraph:
sentence_str = "".join(token.spacing + token.value for token in sentence).strip()
if sentence_str:
sentences.append(sentence_str)
if len(sentences) < 2:
return tests
all_indexes = list(range(len(sentences)))
random.shuffle(all_indexes)
random_pairs = [(all_indexes[i * 2], all_indexes[i * 2 + 1]) for i in range(len(all_indexes) // 2)]
random_pairs = [(min(i, j), max(i, j)) for (i, j) in random_pairs]
num_order_tests = 0
for i, j in random_pairs:
first_sentence = sentences[i]
second_sentence = sentences[j]
if len(first_sentence) < 10 or len(second_sentence) < 10:
continue
first_sentence = first_sentence.split("\n")[0].strip()
second_sentence = second_sentence.split("\n")[0].strip()
max_diffs = round(max(len(first_sentence), len(second_sentence)) * 0.05)
if max_diffs > len(first_sentence) // 2 or max_diffs > len(second_sentence) // 2:
continue
tests.append(
{
"pdf": pdf_filename,
"page": page_num,
"id": f"{pdf_id}_order_{uuid.uuid4().hex[:8]}",
"type": "order",
"before": first_sentence,
"after": second_sentence,
"max_diffs": max_diffs,
}
)
num_order_tests += 1
if num_order_tests >= 5:
break
return tests
async def render_pdf_with_playwright(html_content, output_pdf_path, png_width, png_height):
"""
Render HTML content using Playwright and save it as a PDF.
Tries different scale factors until the output PDF has exactly one page.
"""
scale_factors = [1.0, 0.9, 0.8, 0.7, 0.6, 0.5]
for scale in scale_factors:
try:
async with async_playwright() as p:
browser = await p.chromium.launch()
page = await browser.new_page(viewport={"width": int(png_width // 2 * scale), "height": int(png_height // 2 * scale)})
await page.set_content(html_content)
# Add KaTeX assets for math rendering
katex_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "katex")
katex_css_path = os.path.join(katex_dir, "katex.min.css")
katex_js_path = os.path.join(katex_dir, "katex.min.js")
katex_autorender_js_path = os.path.join(katex_dir, "auto-render.min.js")
await page.add_style_tag(path=katex_css_path)
await page.add_script_tag(path=katex_js_path)
await page.add_script_tag(path=katex_autorender_js_path)
await page.evaluate("""
renderMathInElement(document.body, {
delimiters: [
{left: '\\\$begin:math:text$', right: '\\\\\\$end:math:text$', display: false},
{left: '\\\$begin:math:display$', right: '\\\\\\$end:math:display$', display: true}
],
throwOnError: false
});
""")
await page.pdf(path=output_pdf_path, scale=scale, print_background=True)
await browser.close()
try:
reader = pypdf.PdfReader(output_pdf_path)
if len(reader.pages) == 1:
print(f"Successfully rendered as a single page PDF with scale factor {scale}")
return True
else:
print(f"PDF has {len(reader.pages)} pages with scale factor {scale}, trying a smaller scale...")
except Exception as pdf_check_error:
print(f"Error checking PDF page count: {pdf_check_error}")
return False
except Exception as e:
print(f"Error rendering PDF with Playwright at scale {scale}: {e}")
print("Failed to render PDF as a single page with any scale factor")
return False
def process_pdf(pdf_info, args, client):
"""
Process a single PDF from a local folder:
- Select a random page from the PDF.
- Render that page as a base64 PNG.
- Generate HTML from the image using Claude.
- Optionally render the HTML to a PDF using Playwright.
- Generate order tests from the HTML.
"""
local_pdf_path, index = pdf_info
original_pdf_name = os.path.basename(local_pdf_path)
pdf_id = original_pdf_name
temp_pdf_dir = os.path.join(args.temp_dir, f"{os.path.splitext(original_pdf_name)[0]}")
os.makedirs(temp_pdf_dir, exist_ok=True)
try:
reader = pypdf.PdfReader(local_pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF has no pages: {local_pdf_path}")
return None
page_num = random.randint(1, num_pages)
except Exception as e:
print(f"Error reading {local_pdf_path}: {e}")
return None
try:
image_base64 = render_pdf_to_base64png(local_pdf_path, page_num, target_longest_image_dim=2048)
except Exception as e:
print(f"Error rendering page {page_num} from {local_pdf_path}: {e}")
return None
try:
html_content = generate_html_from_image(client, image_base64)
if not html_content:
print(f"Failed to generate HTML for {local_pdf_path}, page {page_num}")
return None
except Exception as e:
print(f"Error generating HTML for {local_pdf_path}: {e}")
return None
html_dir = os.path.join(args.output_dir, "html")
pdfs_dir = os.path.join(args.output_dir, "pdfs")
os.makedirs(html_dir, exist_ok=True)
os.makedirs(pdfs_dir, exist_ok=True)
html_path = os.path.join(html_dir, f"{os.path.splitext(original_pdf_name)[0]}_page{page_num}.html")
try:
with open(html_path, "w") as f:
f.write(html_content)
except Exception as e:
print(f"Error saving HTML for {local_pdf_path}: {e}")
return None
playwright_pdf_path = None
render_success = False
playwright_pdf_filename = f"{os.path.splitext(original_pdf_name)[0]}_page{page_num}.pdf"
if not args.skip_playwright:
playwright_pdf_path = os.path.join(pdfs_dir, playwright_pdf_filename)
try:
png_width, png_height = get_png_dimensions_from_base64(image_base64)
render_success = asyncio.run(render_pdf_with_playwright(html_content, playwright_pdf_path, png_width, png_height))
if render_success:
print(f"Successfully rendered with Playwright: {playwright_pdf_path}")
else:
print(f"Failed to render as a single page PDF: {playwright_pdf_path}")
playwright_pdf_path = None
except Exception as e:
print(f"Failed to render with Playwright: {e}")
playwright_pdf_path = None
render_success = False
if not args.skip_playwright and not render_success:
return None
tests = generate_tests_from_html(html_content, pdf_id, page_num)
# IMPORTANT: Preserve the original PDF filename in the tests.
for test in tests:
test["pdf"] = original_pdf_name
try:
if os.path.exists(temp_pdf_dir):
subprocess.run(["rm", "-rf", temp_pdf_dir])
except Exception as e:
print(f"Error cleaning up temp directory {temp_pdf_dir}: {e}")
return {
"pdf_id": pdf_id,
"pdf_path": local_pdf_path,
"page_number": page_num,
"html_path": html_path,
"playwright_pdf_path": playwright_pdf_path,
"tests": tests,
"num_tests": len(tests),
}
def main():
parser = argparse.ArgumentParser(description="Convert PDFs in a folder to HTML templates and render with Playwright (order tests only)")
parser.add_argument("--input_dir", required=True, help="Folder containing PDF files")
parser.add_argument("--output_dir", required=True, help="Directory to store HTML and tests")
parser.add_argument("--temp_dir", default="/tmp/mine_tables", help="Directory for temporary files")
parser.add_argument("--max_tests", type=int, default=100, help="Maximum number of PDFs to process (randomly selected)")
parser.add_argument("--parallel", type=int, default=1, help="Number of parallel threads to use")
parser.add_argument("--api_key", help="Claude API key (or set ANTHROPIC_API_KEY environment variable)")
parser.add_argument("--skip_playwright", action="store_true", help="Skip Playwright PDF rendering")
parser.add_argument("--verbose", action="store_true", help="Enable verbose output")
args = parser.parse_args()
os.makedirs(args.output_dir, exist_ok=True)
os.makedirs(args.temp_dir, exist_ok=True)
api_key = args.api_key or os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
print("Error: API key not provided. Use --api_key or set ANTHROPIC_API_KEY environment variable.")
return
client = Anthropic(api_key=api_key)
pdf_paths = []
for root, dirs, files in os.walk(args.input_dir):
for file in files:
if file.lower().endswith(".pdf"):
pdf_paths.append(os.path.join(root, file))
print(f"Found {len(pdf_paths)} PDF files in {args.input_dir}")
random.shuffle(pdf_paths)
pdf_paths = pdf_paths[: args.max_tests]
synthetic_json_path = os.path.join(args.output_dir, "synthetic.jsonl")
open(synthetic_json_path, "w").close()
test_counter = 0
test_types = {"order": 0}
results = []
import threading
file_lock = threading.Lock()
with ThreadPoolExecutor(max_workers=args.parallel) as executor:
futures = {executor.submit(process_pdf, (pdf_path, i), args, client): pdf_path for i, pdf_path in enumerate(pdf_paths)}
for future in tqdm(concurrent.futures.as_completed(futures), total=len(futures), desc="Processing PDFs"):
pdf_path = futures[future]
try:
result = future.result()
if result and result.get("tests"):
results.append(result)
with file_lock:
with open(synthetic_json_path, "a") as f:
for test in result["tests"]:
f.write(json.dumps(test) + "\n")
test_counter += len(result["tests"])
for test in result["tests"]:
if test.get("type") == "order":
test_types["order"] += 1
print(f"Added {len(result['tests'])} tests from {result['pdf_id']}, total: {test_counter}")
except Exception as e:
print(f"Error processing {pdf_path}: {e}")
print(f"Generated {len(results)} HTML templates")
if not args.skip_playwright:
playwright_success = sum(1 for r in results if r and r.get("playwright_pdf_path"))
print(f"Playwright PDF rendering: {playwright_success}/{len(results)} successful")
print(f"Saved {test_counter} tests to {synthetic_json_path}")
print(f"Generated a total of {test_counter} tests across {len(results)} templates")
print("Test type distribution:")
for test_type, count in test_types.items():
print(f" - {test_type}: {count} tests")
if __name__ == "__main__":
main()
@@ -0,0 +1,295 @@
#!/usr/bin/env python3
"""
mine_multilingual_gpt.py - Identify PDF documents with tables and copy them.
This script:
1. Takes a file containing S3 paths to PDF documents as input
2. For each PDF, renders a random page and uses GPT-4o to check the primary language
3. Identifies PDFs where the page is not in english
4. Copies those PDF files to a new output folder
Usage:
python mine_multilingual_gpt.py --input_list path/to/s3_paths.txt --output_dir path/to/output --api_key your_openai_api_key
"""
import argparse
import os
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
import boto3
import pypdf
from lingua import Language
from openai import OpenAI
from pydantic import BaseModel
from tqdm import tqdm
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.filter import PdfFilter
TARGET_IMAGE_DIM = 1024
class LanguageDetectionResponse(BaseModel):
"""Structured output for table detection."""
two_letter_language_code: str
def download_pdf_from_s3(s3_path: str, local_path: str) -> bool:
"""
Download a PDF file from S3.
Args:
s3_path: The S3 path (s3://bucket/path/to/file.pdf)
local_path: The local path to save the file
Returns:
bool: True if download was successful, False otherwise
"""
try:
# Parse S3 path
parts = s3_path.replace("s3://", "").split("/", 1)
bucket = parts[0]
key = parts[1]
# Create S3 client
s3 = boto3.client("s3")
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(local_path), exist_ok=True)
# Download file
s3.download_file(bucket, key, local_path)
return True
except Exception as e:
print(f"Error downloading {s3_path}: {str(e)}")
return False
def check_for_table(pdf_path: str, page_num: int, api_key: str) -> Optional[bool]:
"""
Use GPT-4o to check if a page contains a table.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
api_key: OpenAI API key
Returns:
Optional[bool]: True if page contains a table, False otherwise, None if detection fails
"""
# Initialize OpenAI client
client = OpenAI(api_key=api_key)
try:
# Render the PDF page as an image (render_pdf_to_base64png is 1-indexed)
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=TARGET_IMAGE_DIM)
# Simple prompt asking about tables
prompt = "Respond with the primary language that this document is written in. Reply in JSON, and use a two letter ISO 639 language code, such as 'en', 'pt', 'fr', etc."
response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{
"role": "user",
"content": [{"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}}],
}
],
temperature=0.1,
max_tokens=100,
response_format=LanguageDetectionResponse,
)
if not response.choices or len(response.choices) == 0:
print(f"No response generated for {pdf_path} page {page_num}")
return None
# Parse the structured response
parsed_response = response.choices[0].message.parsed
if parsed_response is None:
print(f"Failed to parse response for {pdf_path} page {page_num}")
return None
is_non_english = parsed_response.two_letter_language_code.lower() != "en"
if is_non_english:
print(f"Found {parsed_response.two_letter_language_code} language in {pdf_path} page {page_num + 1}")
return is_non_english
except Exception as e:
print(f"Error checking {pdf_path} page {page_num}: {str(e)}")
return None
def process_pdf(s3_path: str, temp_dir: str, output_dir: str, api_key: str) -> bool:
"""
Process a single PDF from S3.
Args:
s3_path: S3 path to the PDF
temp_dir: Directory for temporary files
output_dir: Directory for output files
api_key: OpenAI API key
Returns:
bool: True if the PDF has a table and was copied, False otherwise
"""
# Extract filename from S3 path
pdf_filename = os.path.basename(s3_path)
local_pdf_path = os.path.join(temp_dir, pdf_filename)
# Download PDF from S3
if not download_pdf_from_s3(s3_path, local_pdf_path):
return False
pdf_filter = PdfFilter(languages_to_keep=Language.all())
if pdf_filter.filter_out_pdf(local_pdf_path):
print(f"Filtering out {pdf_filename}")
return False
try:
# Read the PDF to get the number of pages
reader = pypdf.PdfReader(local_pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF {pdf_filename} has no pages")
return False
# Select a random page to check
page_num = random.randint(0, num_pages - 1)
page_num = random.choice([page_num, 0]) # Bias 50% of the time to do the first page
# Check if the page contains a table
has_table = check_for_table(local_pdf_path, page_num, api_key)
if has_table:
# Extract just the page with the table and save it as a new PDF
os.makedirs(output_dir, exist_ok=True)
# Create output filename with basename_pgnum.pdf format
pdf_basename = os.path.splitext(pdf_filename)[0]
output_pdf_path = os.path.join(output_dir, f"{pdf_basename}_pg{page_num+1}.pdf")
# Extract the single page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[page_num])
# Write the output PDF
with open(output_pdf_path, "wb") as output_file:
writer.write(output_file)
print(f"Extracted page {page_num+1} with table from {pdf_filename} to {os.path.basename(output_pdf_path)}")
return True
return False
except Exception as e:
print(f"Error processing {pdf_filename}: {str(e)}")
return False
finally:
if os.path.exists(local_pdf_path):
os.remove(local_pdf_path)
def main():
parser = argparse.ArgumentParser(description="Identify and copy PDFs with tables")
parser.add_argument("--input_list", required=True, help="Path to a file containing S3 paths to PDFs")
parser.add_argument("--output_dir", required=True, help="Directory to copy PDFs with tables")
parser.add_argument("--api_key", help="OpenAI API key (if not provided, will use OPENAI_API_KEY environment variable)")
parser.add_argument("--temp_dir", default="/tmp/mine_tables", help="Directory for temporary files")
parser.add_argument("--max_pdfs", type=int, default=100, help="Maximum number of PDFs with tables to find")
parser.add_argument("--parallel", type=int, default=1, help="Number of parallel workers (default: 1 for sequential)")
parser.add_argument("--reservoir_multiplier", type=int, default=100, help="Multiplier for reservoir sampling (default: 100x max_pdfs)")
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("OPENAI_API_KEY")
if not api_key:
print("Error: OpenAI API key not provided. Use --api_key or set OPENAI_API_KEY environment variable.")
return
os.makedirs(args.temp_dir, exist_ok=True)
os.makedirs(args.output_dir, exist_ok=True)
# Reservoir sampling to get random subset of PDFs
reservoir_size = args.max_pdfs * args.reservoir_multiplier
pdf_paths = []
n = 0 # Total number of items seen
print(f"Using reservoir sampling with size {reservoir_size}")
with open(args.input_list, "r") as f:
for line in f:
n += 1
path = line.strip()
if not path:
continue
if len(pdf_paths) < reservoir_size:
pdf_paths.append(path)
else:
# Randomly decide whether to include this item
s = random.randint(1, n)
if s <= reservoir_size:
pdf_paths[s - 1] = path
# Shuffle the reservoir
random.shuffle(pdf_paths)
print(f"Sampled {len(pdf_paths)} PDF paths from {n} total paths")
table_pdfs_found = 0
if args.parallel > 1:
# Parallel processing
print(f"Processing PDFs with {args.parallel} parallel workers")
with ThreadPoolExecutor(max_workers=args.parallel) as executor:
futures = []
# Submit all tasks
for s3_path in pdf_paths:
if table_pdfs_found >= args.max_pdfs:
break
future = executor.submit(process_pdf, s3_path, args.temp_dir, args.output_dir, api_key)
futures.append(future)
# Process results as they complete
with tqdm(total=min(len(pdf_paths), args.max_pdfs), desc="Processing PDFs") as pbar:
for future in as_completed(futures):
try:
result = future.result()
if result:
table_pdfs_found += 1
pbar.update(1)
if table_pdfs_found >= args.max_pdfs:
print(f"Reached maximum number of PDFs with tables ({args.max_pdfs}), stopping")
# Cancel remaining futures
for f in futures:
f.cancel()
break
except Exception as e:
print(f"Error in parallel processing: {str(e)}")
else:
# Sequential processing
for s3_path in tqdm(pdf_paths, desc="Processing PDFs"):
if process_pdf(s3_path, args.temp_dir, args.output_dir, api_key):
table_pdfs_found += 1
if table_pdfs_found >= args.max_pdfs:
print(f"Reached maximum number of PDFs with tables ({args.max_pdfs}), stopping")
break
print(f"Found and copied {table_pdfs_found} PDFs to {args.output_dir}")
if __name__ == "__main__":
main()
+120
View File
@@ -0,0 +1,120 @@
import json
import os
import time
import requests
from bs4 import BeautifulSoup
def process_jsonl(jsonl_file, output_dir="downloaded_images"):
"""
Process each line in the JSONL file and download the corresponding images.
Args:
jsonl_file (str): Path to the JSONL file
output_dir (str): Directory to save downloaded images
"""
os.makedirs(output_dir, exist_ok=True)
with open(jsonl_file, "r") as f:
for line_num, line in enumerate(f, 1):
try:
data = json.loads(line.strip())
pdf_path = data.get("pdf", "")
pdf_filename = os.path.basename(pdf_path)
url = data.get("url", "")
if not url:
print(f"Line {line_num}: Missing URL, skipping...")
continue
print(f"Processing line {line_num}: {pdf_filename} - {url}")
image_path = download_image(url, pdf_filename, output_dir)
if image_path:
print(f"Successfully downloaded: {image_path}")
else:
print(f"Failed to download image for {pdf_filename}")
time.sleep(1)
except json.JSONDecodeError:
print(f"Line {line_num}: Invalid JSON, skipping...")
except Exception as e:
print(f"Line {line_num}: Error processing - {str(e)}")
def download_image(url, output_filename, output_dir):
"""
Download the highest resolution JPEG (before JPEG2000) from the Library of Congress URL.
Args:
url (str): The initial URL from the JSONL file
output_filename (str): Filename for the downloaded image
output_dir (str): Directory to save the image
Returns:
str or None: Path to the downloaded image if successful, None otherwise
"""
try:
response = requests.get(url)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
loc_link = soup.find("a", class_="btn btn-outline-primary text-nowrap", title="View the original source for this item in a new tab")
if not loc_link:
print(f"Could not find 'View on www.loc.gov' link on {url}")
return None
loc_url = loc_link["href"]
print(f"Found LOC URL: {loc_url}")
response = requests.get(loc_url)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
download_select = soup.find("select", id="download")
if not download_select:
print(f"Could not find download options on {loc_url}")
return None
jpeg_options = [option for option in download_select.find_all("option") if "JPEG" in option.text and "JPEG2000" not in option.text]
if not jpeg_options:
print(f"No JPEG options found on {loc_url}")
return None
highest_jpeg = jpeg_options[-1]
image_url = highest_jpeg["value"]
print(f"Found highest resolution JPEG: {image_url}")
response = requests.get(image_url, stream=True)
response.raise_for_status()
if not output_filename.lower().endswith((".jpg", ".jpeg")):
output_filename = f"{os.path.splitext(output_filename)[0]}.jpg"
output_path = os.path.join(output_dir, output_filename)
with open(output_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
return output_path
except Exception as e:
print(f"Error downloading image: {str(e)}")
return None
def main():
import argparse
parser = argparse.ArgumentParser(description="Download images from Library of Congress based on JSONL file")
parser.add_argument("jsonl_file", help="Path to the JSONL file containing URLs")
parser.add_argument("--output", "-o", default="downloaded_images", help="Directory to save downloaded images (default: downloaded_images)")
args = parser.parse_args()
print(f"Processing JSONL file: {args.jsonl_file}")
process_jsonl(args.jsonl_file, args.output)
print("Processing complete!")
if __name__ == "__main__":
main()
+122
View File
@@ -0,0 +1,122 @@
import json
import random
import re
def extract_random_segment(text, min_words=7, max_words=15):
"""Extract a random segment of 7-15 words from the text."""
words = text.split()
if len(words) <= max_words:
return text # Return full text if it's shorter than max_words
max_start = len(words) - min_words
start = random.randint(0, max_start)
remaining_words = len(words) - start
segment_length = random.randint(min_words, min(max_words, remaining_words))
segment = words[start : start + segment_length]
return " ".join(segment)
def process_jsonl_file_present(input_file, output_file):
"""Process a JSONL file and create multiple random cases for each PDF."""
with open(input_file, "r") as infile, open(output_file, "w") as outfile:
for line in infile:
if line.strip(): # Skip empty lines
data = json.loads(line)
image = data["image"]
original_text = data["text"]
num_cases = random.randint(1, 3)
for _ in range(num_cases):
processed_num = random.randint(5, 10)
processed_id = f"{image}_processed{processed_num:02d}"
max_diffs = random.randint(1, 2)
text_segment = extract_random_segment(original_text)
new_case = {
"pdf": f"{image}.pdf",
"page": 1,
"id": processed_id,
"type": "present",
"max_diffs": max_diffs,
"text": text_segment,
"case_sensitive": True,
"first_n": None,
"last_n": None,
}
outfile.write(json.dumps(new_case) + "\n")
def extract_ordered_segments(text, min_words=7, max_words=15):
"""Extract two ordered segments from the text."""
sentences = re.split(r"(?<=[.!?])\s+", text)
if len(sentences) < 2:
return None, None
valid_indices = list(range(len(sentences)))
if len(valid_indices) <= 2:
before_idx, after_idx = 0, 1
else:
before_idx = random.randint(0, len(valid_indices) - 2)
after_idx = random.randint(before_idx + 1, len(valid_indices) - 1)
before_sentence = sentences[before_idx]
after_sentence = sentences[after_idx]
before_words = before_sentence.split()
after_words = after_sentence.split()
if len(before_words) > max_words:
start = random.randint(0, len(before_words) - min_words)
length = random.randint(min_words, min(max_words, len(before_words) - start))
before_segment = " ".join(before_words[start : start + length])
else:
before_segment = before_sentence
if len(after_words) > max_words:
start = random.randint(0, len(after_words) - min_words)
length = random.randint(min_words, min(max_words, len(after_words) - start))
after_segment = " ".join(after_words[start : start + length])
else:
after_segment = after_sentence
return before_segment, after_segment
def process_jsonl_file_order(input_file, output_file):
"""Process a JSONL file and create order-type cases."""
with open(input_file, "r") as infile, open(output_file, "w") as outfile:
for line in infile:
if line.strip(): # Skip empty lines
data = json.loads(line)
image = data["image"]
original_text = data["text"]
num_cases = random.randint(1, 3)
for _ in range(num_cases):
before_text, after_text = extract_ordered_segments(original_text)
if not before_text or not after_text:
continue
processed_num = random.randint(11, 16)
processed_id = f"{image}_processed{processed_num:02d}"
max_diffs = random.randint(1, 3)
new_case = {
"pdf": f"{image}.pdf",
"page": 1,
"id": processed_id,
"type": "order",
"before": before_text,
"after": after_text,
"max_diffs": max_diffs,
"checked": "verified",
"url": f"https://example.com/document/{image}",
}
outfile.write(json.dumps(new_case) + "\n")
if __name__ == "__main__":
input_file = "olmoce/bench/sample_data/old_scans.jsonl"
output_file = "order_cases.jsonl"
process_jsonl_file_present(input_file, output_file)
process_jsonl_file_order(input_file, output_file)
+277
View File
@@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""
mine_old_scans_math.py - Extract mathematical equations from PDF documents.
This script:
1. Takes a folder containing PDF documents as input
2. For each PDF, extracts a random page and renders it to an image
3. Uses Gemini to identify mathematical equations in the rendered image
4. Creates a test file asserting that the equation text should be present
5. Extracts the page from the PDF and saves it to an output folder
Usage:
python mine_old_scans_math.py --input_dir path/to/pdf_folder --output_dir path/to/output --api_key your_gemini_api_key
"""
import argparse
import base64
import json
import os
import random
from typing import List, Optional
import pypdf
from google import genai
from google.genai import types
from tqdm import tqdm
from olmocr.bench.tests import TextPresenceTest, save_tests
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.filter import PdfFilter
def extract_page_from_pdf(input_path: str, output_path: str, page_num: int) -> bool:
"""
Extract a specific page from a PDF and save it as a new PDF.
Args:
input_path: Path to the input PDF
output_path: Path to save the extracted page
page_num: The page number to extract (0-indexed)
Returns:
bool: True if extraction was successful, False otherwise
"""
try:
# Ensure output directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Read the input PDF
reader = pypdf.PdfReader(input_path)
# Check if page number is valid
if page_num >= len(reader.pages):
print(f"Page number {page_num} out of range for {input_path} with {len(reader.pages)} pages")
return False
# Create a new PDF with just the selected page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[page_num])
# Write the output PDF
with open(output_path, "wb") as output_file:
writer.write(output_file)
return True
except Exception as e:
print(f"Error extracting page {page_num} from {input_path}: {str(e)}")
raise
def detect_equations(pdf_path: str, page_num: int, api_key: str) -> Optional[List[str]]:
"""
Use Gemini to detect mathematical equations in a rendered PDF page.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
api_key: Gemini API key
Returns:
Optional[List[str]]: List of detected equations, or None if detection failed
"""
client = genai.Client(
api_key=os.environ.get("GEMINI_API_KEY"),
)
model = "gemini-2.0-flash"
# Render the PDF page as an image
try:
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=2048) # render_pdf_to_base64png is 1-indexed
except Exception as e:
print(f"Error rendering PDF page: {str(e)}")
return None
image_part = types.Part(inline_data=types.Blob(mime_type="image/png", data=base64.b64decode(image_base64)))
contents = [
types.Content(
role="user",
parts=[
image_part,
types.Part.from_text(
text="""Please extract the mathematical equations from the document without omission. Always output the mathematical equations as Latex escaped with $$. Do not hallucinate"""
),
],
),
]
generate_content_config = types.GenerateContentConfig(
temperature=1,
top_p=0.95,
top_k=40,
max_output_tokens=8192,
response_mime_type="application/json",
response_schema=genai.types.Schema(
type=genai.types.Type.OBJECT,
properties={
"equations": genai.types.Schema(
type=genai.types.Type.ARRAY,
items=genai.types.Schema(
type=genai.types.Type.STRING,
),
),
},
),
)
response = client.models.generate_content(model=model, contents=contents, config=generate_content_config)
assert len(response.candidates) > 0, "No candidates found"
assert response.candidates[0].finish_reason == types.FinishReason.STOP, "Finish reason was not STOP, likely a processing error or repetition failure"
data = json.loads(response.candidates[0].content.parts[0].text)
return data.get("equations", [])
def process_pdf(pdf_path: str, output_dir: str, api_key: str, tests: List[TextPresenceTest], max_pages_per_pdf: int = 10) -> None:
"""
Process a single PDF, extracting equations from multiple pages.
Args:
pdf_path: Path to the PDF
output_dir: Directory for output files
api_key: Gemini API key
tests: List to append tests to
max_pages_per_pdf: Maximum number of pages to process per PDF
"""
# Extract filename from path
pdf_filename = os.path.basename(pdf_path)
pdf_filter = PdfFilter()
if pdf_filter.filter_out_pdf(pdf_path):
print("Filtering out", pdf_filename)
return
try:
# Read the PDF to get the number of pages
reader = pypdf.PdfReader(pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF {pdf_filename} has no pages")
return
# Get all pages and shuffle them to select a random subset
all_pages = list(range(num_pages))
random.shuffle(all_pages)
# Take only the specified maximum number of pages
pages_to_process = all_pages[: min(max_pages_per_pdf, num_pages)]
processed_pages = 0
for page_num in pages_to_process:
# Detect equations
equations = detect_equations(pdf_path, page_num, api_key)
# Only keep equations that are non-empty
equations = [eq for eq in equations if len(eq.strip()) > 3]
if not equations:
print(f"No equations detected in {pdf_filename} page {page_num+1}")
continue
# Extract the page and save to output dir
pdf_basename = os.path.splitext(pdf_filename)[0]
output_pdf_path = os.path.join(output_dir, "pdfs", f"{pdf_basename}_pg{page_num+1}.pdf")
extract_page_from_pdf(pdf_path, output_pdf_path, page_num)
# Create tests for each equation
for i, equation in enumerate(equations):
test_id = f"{pdf_basename}_pg{page_num+1}_equation_{i:02d}"
test = TextPresenceTest(
id=test_id,
pdf=f"{pdf_basename}_pg{page_num+1}.pdf",
page=1, # The extracted PDF has only one page
type="present",
text=equation,
max_diffs=0,
)
tests.append(test)
print(f"Processed {pdf_filename} page {page_num+1}, found {len(equations)} equations")
processed_pages += 1
print(f"Completed processing {processed_pages} pages from {pdf_filename}")
except Exception as e:
print(f"Error processing {pdf_filename}: {str(e)}")
def get_pdf_files_from_directory(directory: str) -> List[str]:
"""
Get a list of all PDF files in a directory.
Args:
directory: Path to the directory containing PDFs
Returns:
List[str]: List of full paths to PDF files
"""
pdf_files = []
for filename in os.listdir(directory):
if filename.lower().endswith(".pdf"):
full_path = os.path.join(directory, filename)
if os.path.isfile(full_path):
pdf_files.append(full_path)
return pdf_files
def main():
parser = argparse.ArgumentParser(description="Extract mathematical equations from PDF documents")
parser.add_argument("--input_dir", required=True, help="Directory containing PDF files")
parser.add_argument("--output_dir", required=True, help="Directory to store extracted pages and tests")
parser.add_argument("--api_key", help="Gemini API key (if not provided, will use GEMINI_API_KEY environment variable)")
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("GEMINI_API_KEY")
if not api_key:
print("Error: Gemini API key not provided. Use --api_key or set GEMINI_API_KEY environment variable.")
return
# Create directories
os.makedirs(os.path.join(args.output_dir, "pdfs"), exist_ok=True)
# Get PDF files from input directory
pdf_files = get_pdf_files_from_directory(args.input_dir)
if not pdf_files:
print(f"No PDF files found in {args.input_dir}")
return
print(f"Found {len(pdf_files)} PDF files in input directory")
# Process each PDF
tests = []
for pdf_path in tqdm(pdf_files, desc="Processing PDFs"):
process_pdf(pdf_path, args.output_dir, api_key, tests)
# Save tests after each PDF to avoid losing data in case of crashes
if tests:
save_tests(tests, os.path.join(args.output_dir, "equation_tests.jsonl"))
# if len(tests) > 100:
# break
print(f"Saved {len(tests)} tests to {os.path.join(args.output_dir, 'equation_tests.jsonl')}")
if __name__ == "__main__":
main()
+530
View File
@@ -0,0 +1,530 @@
#!/usr/bin/env python3
"""
analyze_documents.py - Analyze document layout and extract content from PDF documents.
This script:
1. Takes a file containing S3 paths to PDF documents as input
2. For each PDF, extracts a random page and renders it to an image
3. Uses Gemini to analyze document layout features (columns, articles, text inserts, etc.)
4. If specific layout features are detected, proceeds with full document content extraction
5. Extracts the page from the PDF and saves it to an output folder along with analysis results
Usage:
python analyze_documents.py --input_list path/to/s3_paths.txt --output_dir path/to/output --api_key your_gemini_api_key [--parallel 4]
"""
import argparse
import base64
import concurrent.futures
import json
import os
import random
import threading
from typing import Any, Dict, List, Optional, Tuple
import boto3
import pypdf
from google import genai
from google.genai import types
from tqdm import tqdm
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.filter import PdfFilter
# Create a thread-safe lock for writing to output files
file_lock = threading.Lock()
def download_pdf_from_s3(s3_path: str, local_path: str) -> bool:
"""
Download a PDF file from S3.
Args:
s3_path: The S3 path (s3://bucket/path/to/file.pdf)
local_path: The local path to save the file
Returns:
bool: True if download was successful, False otherwise
"""
try:
# Parse S3 path
parts = s3_path.replace("s3://", "").split("/", 1)
bucket = parts[0]
key = parts[1]
# Create S3 client
s3 = boto3.client("s3")
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(local_path), exist_ok=True)
# Download file
s3.download_file(bucket, key, local_path)
return True
except Exception as e:
print(f"Error downloading {s3_path}: {str(e)}")
return False
def extract_page_from_pdf(input_path: str, output_path: str, page_num: int) -> bool:
"""
Extract a specific page from a PDF and save it as a new PDF.
Args:
input_path: Path to the input PDF
output_path: Path to save the extracted page
page_num: The page number to extract (0-indexed)
Returns:
bool: True if extraction was successful, False otherwise
"""
try:
# Ensure output directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Read the input PDF
reader = pypdf.PdfReader(input_path)
# Check if page number is valid
if page_num >= len(reader.pages):
print(f"Page number {page_num} out of range for {input_path} with {len(reader.pages)} pages")
return False
# Create a new PDF with just the selected page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[page_num])
# Write the output PDF
with open(output_path, "wb") as output_file:
writer.write(output_file)
return True
except Exception as e:
print(f"Error extracting page {page_num} from {input_path}: {str(e)}")
raise
def analyze_document_layout(pdf_path: str, page_num: int, api_key: str) -> Optional[Tuple[Dict[str, Any], str]]:
"""
Use Gemini to analyze document layout features in a rendered PDF page.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
api_key: Gemini API key
Returns:
Optional[Tuple[Dict[str, Any], str]]:
A tuple with the layout analysis results as a dictionary and the base64 string of the rendered page image.
Returns None if analysis fails.
"""
# Initialize Gemini client
client = genai.Client(
api_key=api_key,
)
model = "gemini-2.0-flash"
# Render the PDF page as an image (render_pdf_to_base64png is 1-indexed)
try:
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=2048)
except Exception as e:
print(f"Error rendering PDF page: {str(e)}")
return None
image_part = types.Part(inline_data=types.Blob(mime_type="image/png", data=base64.b64decode(image_base64)))
# Prepare prompt for Gemini to analyze document layout
contents = [
types.Content(
role="user",
parts=[
image_part,
types.Part.from_text(
text=(
"Please answer the following questions about the document in JSON format:\n"
"-How many columns are used in the main text document layout?\n"
"-How many unique articles are captured in main text on this page?\n"
"-Are there any text inserts in the main article content?\n"
"-Do any of the main content articles start with a dropcap?\n"
"-Are there any boxed out regions of text that need to be read separately from the main article content?\n"
"-Are there any regions of text with a different orientation/rotation?"
)
),
],
),
]
generate_content_config = types.GenerateContentConfig(
temperature=0.2,
top_p=0.95,
top_k=40,
max_output_tokens=2048,
response_mime_type="application/json",
response_schema=types.Schema(
type=types.Type.OBJECT,
required=[
"num_columns",
"num_unique_articles",
"contains_text_inserts",
"contains_dropcaps",
"contains_boxed_regions",
"contains_text_different_orientation",
],
properties={
"num_columns": types.Schema(
type=types.Type.INTEGER,
),
"num_unique_articles": types.Schema(
type=types.Type.INTEGER,
),
"contains_text_inserts": types.Schema(
type=types.Type.BOOLEAN,
),
"contains_dropcaps": types.Schema(
type=types.Type.BOOLEAN,
),
"contains_boxed_regions": types.Schema(
type=types.Type.BOOLEAN,
),
"contains_text_different_orientation": types.Schema(
type=types.Type.BOOLEAN,
),
},
),
)
try:
# Call Gemini API
response = client.models.generate_content(model=model, contents=contents, config=generate_content_config)
print(response)
if not response.candidates or len(response.candidates) == 0:
print(f"No response generated for {pdf_path} page {page_num}")
return None
if response.candidates[0].finish_reason != types.FinishReason.STOP:
print(f"Response generation incomplete for {pdf_path} page {page_num}")
return None
# Parse the response
response_text = response.candidates[0].content.parts[0].text
layout_analysis = json.loads(response_text)
print(f"Layout analysis for {pdf_path} page {page_num}:")
print(json.dumps(layout_analysis, indent=2))
# Return both the layout analysis and the rendered image (base64 string)
return (layout_analysis, image_base64)
except Exception as e:
print(f"Error analyzing document layout in {pdf_path} page {page_num}: {str(e)}")
return None
def extract_document_content(pdf_path: str, page_num: int, image_base64: str, api_key: str) -> Optional[str]:
"""
Use Gemini to extract full document content from a rendered PDF page.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
image_base64: The base64 string of the rendered page image
api_key: Gemini API key
Returns:
Optional[str]: The extracted document content in markdown format, or None if extraction fails.
"""
# Initialize Gemini client
client = genai.Client(
api_key=api_key,
)
model = "gemini-2.0-flash"
image_part = types.Part(inline_data=types.Blob(mime_type="image/png", data=base64.b64decode(image_base64)))
# Prepare prompt for Gemini to extract document content
contents = [
types.Content(
role="user",
parts=[
image_part,
types.Part.from_text(
text=(
"Analyze the document attached and output it in markdown format. "
"Output equations as Latex escaped with $$. "
"Output tables in HTML format that preserves the structure and content exactly, do not use <br> tags. "
"Instead of the markdown table format, be sure to output tables in HTML, even though the rest of the document is styled in markdown. "
"Output figures with just a simple markdown image placeholder."
)
),
],
),
]
generate_content_config = types.GenerateContentConfig(temperature=0.2, top_p=0.95, top_k=40, max_output_tokens=8192)
try:
# Call Gemini API
response = client.models.generate_content(model=model, contents=contents, config=generate_content_config)
if not response.candidates or len(response.candidates) == 0:
print(f"No response generated for content extraction in {pdf_path} page {page_num}")
return None
if response.candidates[0].finish_reason != types.FinishReason.STOP:
print(f"Content extraction incomplete for {pdf_path} page {page_num}")
return None
# Get the extracted content
content = response.candidates[0].content.parts[0].text
return content
except Exception as e:
print(f"Error extracting document content from {pdf_path} page {page_num}: {str(e)}")
return None
def should_extract_full_content(layout_analysis: Dict[str, Any]) -> bool:
"""
Determine if full content extraction is needed based on layout analysis results.
Args:
layout_analysis: Dictionary containing layout analysis results
Returns:
bool: True if any of the special layout features are detected, False otherwise
"""
# Check for special layout features that warrant full content extraction
features_to_check = ["text_inserts", "dropcaps", "boxed_regions", "rotated_text"]
# Also check if there are multiple columns or articles
try:
columns = layout_analysis.get("columns", 0)
if isinstance(columns, str):
columns = int(columns) if columns.isdigit() else 0
articles = layout_analysis.get("articles", 0)
if isinstance(articles, str):
articles = int(articles) if articles.isdigit() else 0
if columns > 1 or articles > 1:
return True
except (ValueError, TypeError):
# If we can't parse the values, assume we need to extract
pass
# Check for any True values in the features
for feature in features_to_check:
value = layout_analysis.get(feature, False)
if isinstance(value, str):
if value.lower() in ["yes", "true", "1"]:
return True
elif value:
return True
return False
def process_pdf(s3_path: str, temp_dir: str, output_dir: str, api_key: str) -> Dict:
"""
Process a single PDF from S3.
Args:
s3_path: S3 path to the PDF
temp_dir: Directory for temporary files
output_dir: Directory for output files
api_key: Gemini API key
Returns:
Dict: Results of processing the PDF
"""
# Create a thread-specific temp directory to avoid conflicts
thread_id = threading.get_ident()
thread_temp_dir = os.path.join(temp_dir, f"thread_{thread_id}")
os.makedirs(thread_temp_dir, exist_ok=True)
# Extract filename from S3 path
pdf_filename = os.path.basename(s3_path)
local_pdf_path = os.path.join(thread_temp_dir, pdf_filename)
# Download PDF from S3
if not download_pdf_from_s3(s3_path, local_pdf_path):
return {"error": f"Failed to download {s3_path}"}
pdf_filter = PdfFilter()
if pdf_filter.filter_out_pdf(local_pdf_path):
print(f"Filtering out {pdf_filename}")
if os.path.exists(local_pdf_path):
os.remove(local_pdf_path)
return {"error": f"PDF {pdf_filename} filtered out"}
try:
# Read the PDF to get the number of pages
reader = pypdf.PdfReader(local_pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF {pdf_filename} has no pages")
return {"error": f"PDF {pdf_filename} has no pages"}
all_pages = list(range(len(reader.pages)))
random.shuffle(all_pages)
results = {"filename": pdf_filename, "s3_path": s3_path}
for page_num in all_pages:
# Analyze document layout
layout_result = analyze_document_layout(local_pdf_path, page_num, api_key)
if not layout_result:
print(f"Failed to analyze layout in {pdf_filename} page {page_num+1}")
continue
layout_analysis, image_base64 = layout_result
results["layout_analysis"] = layout_analysis
# Determine if we need to extract full content
full_extraction_needed = should_extract_full_content(layout_analysis)
results["full_extraction_needed"] = full_extraction_needed
# Extract full content if needed
if full_extraction_needed:
content = extract_document_content(local_pdf_path, page_num, image_base64, api_key)
results["content"] = content if content else "Content extraction failed"
# Extract the page and save to output dir
pdf_basename = os.path.splitext(pdf_filename)[0]
output_pdf_path = os.path.join(output_dir, "pdfs", f"{pdf_basename}_pg{page_num+1}.pdf")
with file_lock: # Use lock when writing to shared output directory
extract_page_from_pdf(local_pdf_path, output_pdf_path, page_num)
# Save analysis results
output_json_path = os.path.join(output_dir, "results", f"{pdf_basename}_pg{page_num+1}.json")
with file_lock:
os.makedirs(os.path.join(output_dir, "results"), exist_ok=True)
with open(output_json_path, "w") as f:
json.dump(results, f, indent=2)
print(f"Processed {pdf_filename} page {page_num+1}, analysis saved to {output_json_path}")
# Process only one page per PDF
break
return results
except Exception as e:
print(f"Error processing {pdf_filename}: {str(e)}")
return {"error": f"Error processing {pdf_filename}: {str(e)}"}
finally:
# Cleanup
if os.path.exists(local_pdf_path):
os.remove(local_pdf_path)
def process_pdfs_parallel(s3_paths: List[str], temp_dir: str, output_dir: str, api_key: str, max_docs: int, num_workers: int):
"""
Process PDFs in parallel using a thread pool.
Args:
s3_paths: List of S3 paths to PDFs
temp_dir: Directory for temporary files
output_dir: Directory for output files
api_key: Gemini API key
max_docs: Maximum number of documents to process
num_workers: Number of parallel workers to use
"""
# Create output directory structure
os.makedirs(os.path.join(output_dir, "pdfs"), exist_ok=True)
os.makedirs(os.path.join(output_dir, "results"), exist_ok=True)
# Create a summary file
summary_file = os.path.join(output_dir, "summary.jsonl")
# Track processed documents
processed_count = 0
# Create a ThreadPoolExecutor
with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor:
# Submit tasks and track futures
futures = {executor.submit(process_pdf, s3_path, temp_dir, output_dir, api_key): s3_path for s3_path in s3_paths}
# Process results as they complete
for future in concurrent.futures.as_completed(futures):
s3_path = futures[future]
try:
# Get the result from this worker
result = future.result()
# Add to summary file
with file_lock:
with open(summary_file, "a") as f:
f.write(json.dumps(result) + "\n")
# Increment counter if no error
if "error" not in result:
processed_count += 1
print(f"Successfully processed {os.path.basename(s3_path)}, total: {processed_count}")
# Check if we've reached the maximum number of documents
if processed_count >= max_docs:
print(f"Reached maximum number of documents ({max_docs}), stopping")
# Cancel any pending futures
for f in futures:
if not f.done():
f.cancel()
break
except Exception as e:
print(f"Task for {os.path.basename(s3_path)} generated an exception: {e}")
def main():
parser = argparse.ArgumentParser(description="Analyze document layout and extract content from PDF documents")
parser.add_argument("--input_list", required=True, help="Path to a file containing S3 paths to PDFs")
parser.add_argument("--output_dir", required=True, help="Directory to store extracted pages and analysis results")
parser.add_argument("--api_key", help="Gemini API key (if not provided, will use GEMINI_API_KEY environment variable)")
parser.add_argument("--temp_dir", default="/tmp/analyze_documents", help="Directory for temporary files")
parser.add_argument("--max_docs", type=int, default=100, help="Maximum number of documents to process")
parser.add_argument("--parallel", type=int, default=1, help="Number of parallel threads to use")
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("GEMINI_API_KEY")
if not api_key:
print("Error: Gemini API key not provided. Use --api_key or set GEMINI_API_KEY environment variable.")
return
os.makedirs(args.temp_dir, exist_ok=True)
os.makedirs(os.path.join(args.output_dir, "pdfs"), exist_ok=True)
os.makedirs(os.path.join(args.output_dir, "results"), exist_ok=True)
# Reservoir sampling implementation
s3_paths = []
with open(args.input_list, "r") as f:
for i, line in enumerate(tqdm(f)):
line = line.strip()
if not line:
continue
if i < 100000:
s3_paths.append(line)
else:
# Randomly replace elements with decreasing probability
j = random.randint(0, i)
if j < 100000:
s3_paths[j] = line
print(f"Found {len(s3_paths)} PDF paths in input list")
# Determine number of workers to use
num_workers = max(1, min(args.parallel, len(s3_paths)))
print(f"Processing PDFs using {num_workers} parallel workers")
# Process PDFs in parallel
process_pdfs_parallel(s3_paths, args.temp_dir, args.output_dir, api_key, args.max_docs, num_workers)
if __name__ == "__main__":
main()
+491
View File
@@ -0,0 +1,491 @@
#!/usr/bin/env python3
"""
mine_tables.py - Extract tables from PDF documents and create table tests.
This script:
1. Takes a file containing S3 paths to PDF documents as input
2. For each PDF, extracts a random page and renders it to an image
3. Uses Gemini to identify tables in the rendered image
4. Extracts table content and creates table relationship tests by making a second Gemini request
that now includes the page image alongside the prompt (e.g., "Given cell with {cell_value}, which cell is directly to the left of it?")
5. Extracts the page from the PDF and saves it to an output folder
Usage:
python mine_tables.py --input_list path/to/s3_paths.txt --output_dir path/to/output --api_key your_gemini_api_key [--parallel 4]
"""
import argparse
import base64
import concurrent.futures
import os
import random
import threading
from typing import Dict, List, Optional, Tuple
import boto3
import numpy as np
import pypdf
from bs4 import BeautifulSoup
from google import genai
from google.genai import types
from tqdm import tqdm
from olmocr.bench.tests import TableTest, save_tests
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.filter import PdfFilter
# Create a thread-safe lock for writing to the output file
file_lock = threading.Lock()
tests_lock = threading.Lock()
def download_pdf_from_s3(s3_path: str, local_path: str) -> bool:
"""
Download a PDF file from S3.
Args:
s3_path: The S3 path (s3://bucket/path/to/file.pdf)
local_path: The local path to save the file
Returns:
bool: True if download was successful, False otherwise
"""
try:
# Parse S3 path
parts = s3_path.replace("s3://", "").split("/", 1)
bucket = parts[0]
key = parts[1]
# Create S3 client
s3 = boto3.client("s3")
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(local_path), exist_ok=True)
# Download file
s3.download_file(bucket, key, local_path)
return True
except Exception as e:
print(f"Error downloading {s3_path}: {str(e)}")
return False
def extract_page_from_pdf(input_path: str, output_path: str, page_num: int) -> bool:
"""
Extract a specific page from a PDF and save it as a new PDF.
Args:
input_path: Path to the input PDF
output_path: Path to save the extracted page
page_num: The page number to extract (0-indexed)
Returns:
bool: True if extraction was successful, False otherwise
"""
try:
# Ensure output directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Read the input PDF
reader = pypdf.PdfReader(input_path)
# Check if page number is valid
if page_num >= len(reader.pages):
print(f"Page number {page_num} out of range for {input_path} with {len(reader.pages)} pages")
return False
# Create a new PDF with just the selected page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[page_num])
# Write the output PDF
with open(output_path, "wb") as output_file:
writer.write(output_file)
return True
except Exception as e:
print(f"Error extracting page {page_num} from {input_path}: {str(e)}")
raise
def detect_tables(pdf_path: str, page_num: int, api_key: str) -> Optional[Tuple[List[np.ndarray], str]]:
"""
Use Gemini to detect tables in a rendered PDF page.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
api_key: Gemini API key
Returns:
Optional[Tuple[List[np.ndarray], str]]:
A tuple with a list of detected tables (as numpy arrays) and the base64 string of the rendered page image.
Returns None if detection fails.
"""
# Initialize Gemini client
client = genai.Client(
api_key=api_key,
)
model = "gemini-2.0-flash"
# Render the PDF page as an image (render_pdf_to_base64png is 1-indexed)
try:
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=2048)
except Exception as e:
print(f"Error rendering PDF page: {str(e)}")
return None
image_part = types.Part(inline_data=types.Blob(mime_type="image/png", data=base64.b64decode(image_base64)))
# Prepare prompt for Gemini to extract tables
contents = [
types.Content(
role="user",
parts=[
image_part,
types.Part.from_text(
text=(
"Analyze the document attached and output it in markdown format. "
"Output equations as Latex escaped with $$. "
"Output tables in HTML format that preserves the structure and content exactly, do not use <br> tags. "
"Instead of the markdown table format, be sure to output tables in HTML, even though the rest of the document is styled in markdown. "
"Output figures with just a simple markdown image placeholder."
)
),
],
),
]
generate_content_config = types.GenerateContentConfig(temperature=0.2, top_p=0.95, top_k=40, max_output_tokens=8192)
try:
# Call Gemini API
response = client.models.generate_content(model=model, contents=contents, config=generate_content_config)
if not response.candidates or len(response.candidates) == 0:
print(f"No response generated for {pdf_path} page {page_num}")
return None
if response.candidates[0].finish_reason != types.FinishReason.STOP:
print(f"Response generation incomplete for {pdf_path} page {page_num}")
return None
# Parse the response
response_text = response.candidates[0].content.parts[0].text
print(response_text)
# Parse tables from HTML
parsed_tables = []
soup = BeautifulSoup(response_text, "html.parser")
tables = soup.find_all("table")
for table in tables:
rows = table.find_all("tr")
table_data = []
for row in rows:
cells = row.find_all(["th", "td"])
row_data = [cell.get_text().strip() for cell in cells]
table_data.append(row_data)
# Ensure all rows have the same number of columns
if table_data:
max_cols = max(len(row) for row in table_data)
padded_data = [row + [""] * (max_cols - len(row)) for row in table_data]
table_array = np.array(padded_data)
parsed_tables.append(table_array)
# Return both the parsed tables and the rendered image (base64 string)
return (parsed_tables, image_base64) if parsed_tables else None
except Exception as e:
print(f"Error detecting tables in {pdf_path} page {page_num}: {str(e)}")
return None
def generate_table_tests(tables: List[np.ndarray], pdf_image: str, api_key: str, max_tests_per_table: int = 3) -> List[Dict]:
"""
Generate table tests from the detected tables by making a second Gemini request for each candidate cell.
For each candidate cell in a table, the function selects one valid relationship (e.g., "left", "up", "top_heading", etc.)
and sends a prompt to Gemini including the page image. For example:
"Given a cell in a table with value 'XYZ', please answer: which cell is directly to the left of it? Provide only the cell's text."
Args:
tables: List of tables as numpy arrays
pdf_image: Base64 string of the rendered page image
api_key: Gemini API key to use for generating relationship tests
max_tests_per_table: Maximum number of tests to generate per table
Returns:
List of table test dictionaries
"""
tests = []
# Initialize Gemini client for test queries
client = genai.Client(api_key=api_key)
model = "gemini-2.0-flash"
config = types.GenerateContentConfig(temperature=0.2, top_p=0.95, top_k=40, max_output_tokens=100)
# Mapping for relationship prompts
prompt_map = {
"up": "which cell is directly above it?",
"down": "which cell is directly below it?",
"left": "which cell is directly to the left of it?",
"right": "which cell is directly to the right of it?",
"top_heading": "what is the top heading (the heading for the column at the top of the table) for this cell?",
"left_heading": "what is the left heading (the heading for this row on the left part of the table) for this cell?",
}
# Create an image part from the rendered pdf image
image_part = types.Part(inline_data=types.Blob(mime_type="image/png", data=base64.b64decode(pdf_image)))
for table in tables:
rows, cols = table.shape
if table.size == 0 or rows < 2 or cols < 2:
continue # Skip tables that are too small
# Try up to 3x max_tests_per_table candidate cells
candidate_positions = []
for row in range(rows):
for col in range(cols):
if not table[row, col].strip():
continue
if row > 0:
candidate_positions.append((row, col, "up"))
if row < rows - 1:
candidate_positions.append((row, col, "down"))
if col > 0:
candidate_positions.append((row, col, "left"))
if col < cols - 1:
candidate_positions.append((row, col, "right"))
if row > 0:
candidate_positions.append((row, col, "top_heading"))
if col > 0:
candidate_positions.append((row, col, "left_heading"))
random.shuffle(candidate_positions)
tests_for_this_table = 0
for row, col, relationship in candidate_positions:
if tests_for_this_table >= max_tests_per_table:
break
cell_value = table[row, col].strip()
prompt = (
f"Given a cell in a table with value '{cell_value}', please answer: "
f"{prompt_map[relationship]} Provide only the cell's text or output 'null' if there is not a matching cell."
)
try:
contents = [types.Content(role="user", parts=[image_part, types.Part.from_text(text=prompt)])]
response = client.models.generate_content(model=model, contents=contents, config=config)
if not response.candidates or len(response.candidates) == 0 or response.candidates[0].finish_reason != types.FinishReason.STOP:
continue
answer_text = response.candidates[0].content.parts[0].text.strip()
if answer_text and "null" not in answer_text:
test_data = {"cell": cell_value, relationship: answer_text}
tests.append(test_data)
tests_for_this_table += 1
except Exception as e:
print(f"Error querying Gemini for cell '{cell_value}' and relationship '{relationship}': {str(e)}")
return tests
def process_pdf(s3_path: str, temp_dir: str, output_dir: str, api_key: str) -> List[TableTest]:
"""
Process a single PDF from S3.
Args:
s3_path: S3 path to the PDF
temp_dir: Directory for temporary files
output_dir: Directory for output files
api_key: Gemini API key
Returns:
List[TableTest]: List of generated table tests
"""
# Create a thread-specific temp directory to avoid conflicts
thread_id = threading.get_ident()
thread_temp_dir = os.path.join(temp_dir, f"thread_{thread_id}")
os.makedirs(thread_temp_dir, exist_ok=True)
# Extract filename from S3 path
pdf_filename = os.path.basename(s3_path)
local_pdf_path = os.path.join(thread_temp_dir, pdf_filename)
# Download PDF from S3
if not download_pdf_from_s3(s3_path, local_pdf_path):
return []
pdf_filter = PdfFilter()
if pdf_filter.filter_out_pdf(local_pdf_path):
print(f"Filtering out {pdf_filename}")
if os.path.exists(local_pdf_path):
os.remove(local_pdf_path)
return []
try:
# Read the PDF to get the number of pages
reader = pypdf.PdfReader(local_pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF {pdf_filename} has no pages")
return []
all_pages = list(range(len(reader.pages)))
random.shuffle(all_pages)
local_tests = []
for page_num in all_pages:
# Detect tables and obtain the rendered image for this page
result = detect_tables(local_pdf_path, page_num, api_key)
if not result:
print(f"No tables detected in {pdf_filename} page {page_num+1}")
continue
tables, image_base64 = result
# Generate table tests using the new Gemini query approach with the page image
table_tests_data = generate_table_tests(tables, image_base64, api_key, max_tests_per_table=5)
if not table_tests_data:
print(f"Could not generate valid tests for tables in {pdf_filename} page {page_num+1}")
continue
# Extract the page and save to output dir
pdf_basename = os.path.splitext(pdf_filename)[0]
output_pdf_path = os.path.join(output_dir, "pdfs", f"{pdf_basename}_pg{page_num+1}.pdf")
with file_lock: # Use lock when writing to shared output directory
extract_page_from_pdf(local_pdf_path, output_pdf_path, page_num)
# Create table tests
for i, test_data in enumerate(table_tests_data):
test_id = f"{pdf_basename}_pg{page_num+1}_table_{i:02d}"
test = TableTest(
id=test_id,
pdf=f"{pdf_basename}_pg{page_num+1}.pdf",
page=1, # The extracted PDF has only one page
type="table",
cell=test_data["cell"],
url=s3_path, # Added the S3 path as the url field
up=test_data.get("up", None),
down=test_data.get("down", None),
left=test_data.get("left", None),
right=test_data.get("right", None),
top_heading=test_data.get("top_heading", None),
left_heading=test_data.get("left_heading", None),
)
local_tests.append(test)
print(f"Processed {pdf_filename} page {page_num+1}, found {len(tables)} tables, created {len(table_tests_data)} tests")
break # Process only one page per PDF
return local_tests
except Exception as e:
print(f"Error processing {pdf_filename}: {str(e)}")
return []
finally:
# Cleanup
if os.path.exists(local_pdf_path):
os.remove(local_pdf_path)
def process_pdfs_parallel(s3_paths: List[str], temp_dir: str, output_dir: str, api_key: str, max_tests: int, num_workers: int):
"""
Process PDFs in parallel using a thread pool.
Args:
s3_paths: List of S3 paths to PDFs
temp_dir: Directory for temporary files
output_dir: Directory for output files
api_key: Gemini API key
max_tests: Maximum number of tests to generate
num_workers: Number of parallel workers to use
"""
# Create shared resources
all_tests = []
output_file = os.path.join(output_dir, "table_tests.jsonl")
# Create a ThreadPoolExecutor
with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor:
# Submit tasks and track futures
futures = {executor.submit(process_pdf, s3_path, temp_dir, output_dir, api_key): s3_path for s3_path in s3_paths}
# Process results as they complete
for future in concurrent.futures.as_completed(futures):
s3_path = futures[future]
try:
# Get the tests produced by this worker
new_tests = future.result()
# If we got new tests, add them to our collection
if new_tests:
all_tests.extend(new_tests)
save_tests(all_tests, output_file)
print(f"Added {len(new_tests)} tests from {os.path.basename(s3_path)}, total: {len(all_tests)}")
# Check if we've reached the maximum number of tests
if len(all_tests) >= max_tests:
print(f"Reached maximum number of tests ({max_tests}), stopping")
# Cancel any pending futures
for f in futures:
if not f.done():
f.cancel()
break
except Exception as e:
print(f"Task for {os.path.basename(s3_path)} generated an exception: {e}")
def main():
parser = argparse.ArgumentParser(description="Extract tables from PDF documents and create table tests")
parser.add_argument("--input_list", required=True, help="Path to a file containing S3 paths to PDFs")
parser.add_argument("--output_dir", required=True, help="Directory to store extracted pages and tests")
parser.add_argument("--api_key", help="Gemini API key (if not provided, will use GEMINI_API_KEY environment variable)")
parser.add_argument("--temp_dir", default="/tmp/mine_tables", help="Directory for temporary files")
parser.add_argument("--max_tests", type=int, default=100, help="Maximum number of tests to generate")
parser.add_argument("--parallel", type=int, default=1, help="Number of parallel threads to use")
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("GEMINI_API_KEY")
if not api_key:
print("Error: Gemini API key not provided. Use --api_key or set GEMINI_API_KEY environment variable.")
return
os.makedirs(args.temp_dir, exist_ok=True)
os.makedirs(os.path.join(args.output_dir, "pdfs"), exist_ok=True)
# Reservoir sampling implementation
s3_paths = []
with open(args.input_list, "r") as f:
for i, line in enumerate(tqdm(f)):
line = line.strip()
if not line:
continue
if i < 100000:
s3_paths.append(line)
else:
# Randomly replace elements with decreasing probability
j = random.randint(0, i)
if j < 100000:
s3_paths[j] = line
print(f"Found {len(s3_paths)} PDF paths in input list")
# Determine number of workers to use
num_workers = max(1, min(args.parallel, len(s3_paths)))
print(f"Processing PDFs using {num_workers} parallel workers")
# Process PDFs in parallel
process_pdfs_parallel(s3_paths, args.temp_dir, args.output_dir, api_key, args.max_tests, num_workers)
if __name__ == "__main__":
main()
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env python3
"""
mine_tables.py - Extract tables from PDF documents and create table tests.
This script:
1. Takes a file containing S3 paths to PDF documents as input
2. For each PDF, extracts a random page and renders it to an image
3. Uses GPT-4o to identify tables in the rendered image
4. Extracts table content and creates table relationship tests by making a second GPT-4o request
that now includes the page image alongside the prompt (e.g., "Given cell with {cell_value}, which cell is directly to the left of it?")
5. Extracts the page from the PDF and saves it to an output folder
Usage:
python mine_tables.py --input_list path/to/s3_paths.txt --output_dir path/to/output --api_key your_openai_api_key
"""
import argparse
import os
import random
from typing import Dict, List, Optional, Tuple
import boto3
import numpy as np
import pypdf
from bs4 import BeautifulSoup
from openai import OpenAI
from tqdm import tqdm
from olmocr.bench.tests import TableTest, save_tests
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.filter import PdfFilter
def download_pdf_from_s3(s3_path: str, local_path: str) -> bool:
"""
Download a PDF file from S3.
Args:
s3_path: The S3 path (s3://bucket/path/to/file.pdf)
local_path: The local path to save the file
Returns:
bool: True if download was successful, False otherwise
"""
try:
# Parse S3 path
parts = s3_path.replace("s3://", "").split("/", 1)
bucket = parts[0]
key = parts[1]
# Create S3 client
s3 = boto3.client("s3")
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(local_path), exist_ok=True)
# Download file
s3.download_file(bucket, key, local_path)
return True
except Exception as e:
print(f"Error downloading {s3_path}: {str(e)}")
return False
def extract_page_from_pdf(input_path: str, output_path: str, page_num: int) -> bool:
"""
Extract a specific page from a PDF and save it as a new PDF.
Args:
input_path: Path to the input PDF
output_path: Path to save the extracted page
page_num: The page number to extract (0-indexed)
Returns:
bool: True if extraction was successful, False otherwise
"""
try:
# Ensure output directory exists
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Read the input PDF
reader = pypdf.PdfReader(input_path)
# Check if page number is valid
if page_num >= len(reader.pages):
print(f"Page number {page_num} out of range for {input_path} with {len(reader.pages)} pages")
return False
# Create a new PDF with just the selected page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[page_num])
# Write the output PDF
with open(output_path, "wb") as output_file:
writer.write(output_file)
return True
except Exception as e:
print(f"Error extracting page {page_num} from {input_path}: {str(e)}")
raise
def detect_tables(pdf_path: str, page_num: int, api_key: str) -> Optional[Tuple[List[np.ndarray], str]]:
"""
Use GPT-4o to detect tables in a rendered PDF page.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
api_key: OpenAI API key
Returns:
Optional[Tuple[List[np.ndarray], str]]:
A tuple with a list of detected tables (as numpy arrays) and the base64 string of the rendered page image.
Returns None if detection fails.
"""
# Initialize OpenAI client
client = OpenAI(api_key=api_key)
model = "gpt-4o"
# Render the PDF page as an image (render_pdf_to_base64png is 1-indexed)
try:
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=2048)
except Exception as e:
print(f"Error rendering PDF page: {str(e)}")
return None
# Prepare prompt for GPT-4o to extract tables
try:
# Call OpenAI API
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}", "detail": "high"}},
{
"type": "text",
"text": (
"Analyze the document attached and output it in markdown format. "
"Output equations as Latex escaped with $$. "
"Output tables in valid HTML format that preserves the structure and content exactly. "
"Output figures with just a simple markdown image placeholder."
),
},
],
}
],
temperature=0.2,
)
if not response.choices or len(response.choices) == 0:
print(f"No response generated for {pdf_path} page {page_num}")
return None
# Parse the response
response_text = response.choices[0].message.content
print(response_text)
# Parse tables from HTML
parsed_tables = []
soup = BeautifulSoup(response_text, "html.parser")
tables = soup.find_all("table")
for table in tables:
rows = table.find_all("tr")
table_data = []
for row in rows:
cells = row.find_all(["th", "td"])
row_data = [cell.get_text().strip() for cell in cells]
table_data.append(row_data)
# Ensure all rows have the same number of columns
if table_data:
max_cols = max(len(row) for row in table_data)
padded_data = [row + [""] * (max_cols - len(row)) for row in table_data]
table_array = np.array(padded_data)
parsed_tables.append(table_array)
# Return both the parsed tables and the rendered image (base64 string)
return (parsed_tables, image_base64) if parsed_tables else None
except Exception as e:
print(f"Error detecting tables in {pdf_path} page {page_num}: {str(e)}")
return None
def generate_table_tests(tables: List[np.ndarray], pdf_image: str, api_key: str, max_tests_per_table: int = 3) -> List[Dict]:
"""
Generate table tests from the detected tables by making a second GPT-4o request for each candidate cell.
For each candidate cell in a table, the function selects one valid relationship (e.g., "left", "up", "top_heading", etc.)
and sends a prompt to GPT-4o including the page image. For example:
"Given a cell in a table with value 'XYZ', please answer: which cell is directly to the left of it? Provide only the cell's text."
Args:
tables: List of tables as numpy arrays
pdf_image: Base64 string of the rendered page image
api_key: OpenAI API key to use for generating relationship tests
max_tests_per_table: Maximum number of tests to generate per table
Returns:
List of table test dictionaries
"""
tests = []
# Initialize OpenAI client for test queries
client = OpenAI(api_key=api_key)
model = "gpt-4o"
# Mapping for relationship prompts
prompt_map = {
"up": "which cell is directly above it?",
"down": "which cell is directly below it?",
"left": "which cell is directly to the left of it?",
"right": "which cell is directly to the right of it?",
"top_heading": "what is the top heading for this cell?",
"left_heading": "what is the left heading for this cell?",
}
for table in tables:
rows, cols = table.shape
if table.size == 0 or rows < 2 or cols < 2:
continue # Skip tables that are too small
# Try up to 3x max_tests_per_table candidate cells
candidate_positions = []
for _ in range(max_tests_per_table * 3):
row = random.randint(0, rows - 1)
col = random.randint(0, cols - 1)
if not table[row, col].strip():
continue
candidate_positions.append((row, col))
random.shuffle(candidate_positions)
tests_for_this_table = 0
for row, col in candidate_positions:
if tests_for_this_table >= max_tests_per_table:
break
cell_value = table[row, col].strip()
# Determine valid relationship types based on candidate's position
valid_relationships = []
if row > 0:
valid_relationships.append("up")
if row < rows - 1:
valid_relationships.append("down")
if col > 0:
valid_relationships.append("left")
if col < cols - 1:
valid_relationships.append("right")
if row > 0:
valid_relationships.append("top_heading")
if col > 0:
valid_relationships.append("left_heading")
if not valid_relationships:
continue
relationship = random.choice(valid_relationships)
prompt = (
f"Given a cell in a table with value '{cell_value}', please answer: "
f"{prompt_map[relationship]} Provide only the cell's text or output 'null' if there is not a matching cell."
)
try:
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{pdf_image}", "detail": "high"}},
{"type": "text", "text": prompt},
],
}
],
temperature=0.2,
)
if not response.choices or len(response.choices) == 0:
continue
answer_text = response.choices[0].message.content.strip()
if answer_text and "null" not in answer_text:
test_data = {"cell": cell_value, relationship: answer_text}
tests.append(test_data)
tests_for_this_table += 1
except Exception as e:
print(f"Error querying GPT-4o for cell '{cell_value}' and relationship '{relationship}': {str(e)}")
return tests
def process_pdf(s3_path: str, temp_dir: str, output_dir: str, api_key: str, tests: List[TableTest]) -> None:
"""
Process a single PDF from S3.
Args:
s3_path: S3 path to the PDF
temp_dir: Directory for temporary files
output_dir: Directory for output files
api_key: OpenAI API key
tests: List to append tests to
"""
# Extract filename from S3 path
pdf_filename = os.path.basename(s3_path)
local_pdf_path = os.path.join(temp_dir, pdf_filename)
# Download PDF from S3
if not download_pdf_from_s3(s3_path, local_pdf_path):
return
pdf_filter = PdfFilter()
if pdf_filter.filter_out_pdf(local_pdf_path):
print(f"Filtering out {pdf_filename}")
return
try:
# Read the PDF to get the number of pages
reader = pypdf.PdfReader(local_pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF {pdf_filename} has no pages")
return
all_pages = list(range(len(reader.pages)))
random.shuffle(all_pages)
for page_num in all_pages:
# Detect tables and obtain the rendered image for this page
result = detect_tables(local_pdf_path, page_num, api_key)
if not result:
print(f"No tables detected in {pdf_filename} page {page_num+1}")
continue
tables, image_base64 = result
# Generate table tests using the new GPT-4o query approach with the page image
table_tests_data = generate_table_tests(tables, image_base64, api_key, max_tests_per_table=5)
if not table_tests_data:
print(f"Could not generate valid tests for tables in {pdf_filename} page {page_num+1}")
continue
# Extract the page and save to output dir
pdf_basename = os.path.splitext(pdf_filename)[0]
output_pdf_path = os.path.join(output_dir, "pdfs", f"{pdf_basename}_pg{page_num+1}.pdf")
extract_page_from_pdf(local_pdf_path, output_pdf_path, page_num)
# Create table tests
for i, test_data in enumerate(table_tests_data):
test_id = f"{pdf_basename}_pg{page_num+1}_table_{i:02d}"
test = TableTest(
id=test_id,
pdf=f"{pdf_basename}_pg{page_num+1}.pdf",
page=1, # The extracted PDF has only one page
type="table",
cell=test_data["cell"],
up=test_data.get("up", None),
down=test_data.get("down", None),
left=test_data.get("left", None),
right=test_data.get("right", None),
top_heading=test_data.get("top_heading", None),
left_heading=test_data.get("left_heading", None),
)
tests.append(test)
print(f"Processed {pdf_filename} page {page_num+1}, found {len(tables)} tables, created {len(table_tests_data)} tests")
return # Process only one page per PDF
except Exception as e:
print(f"Error processing {pdf_filename}: {str(e)}")
finally:
if os.path.exists(local_pdf_path):
os.remove(local_pdf_path)
def main():
parser = argparse.ArgumentParser(description="Extract tables from PDF documents and create table tests")
parser.add_argument("--input_list", required=True, help="Path to a file containing S3 paths to PDFs")
parser.add_argument("--output_dir", required=True, help="Directory to store extracted pages and tests")
parser.add_argument("--api_key", help="OpenAI API key (if not provided, will use OPENAI_API_KEY environment variable)")
parser.add_argument("--temp_dir", default="/tmp/mine_tables", help="Directory for temporary files")
parser.add_argument("--max_tests", type=int, default=100, help="Maximum number of tests to generate")
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("OPENAI_API_KEY")
if not api_key:
print("Error: OpenAI API key not provided. Use --api_key or set OPENAI_API_KEY environment variable.")
return
os.makedirs(args.temp_dir, exist_ok=True)
os.makedirs(os.path.join(args.output_dir, "pdfs"), exist_ok=True)
with open(args.input_list, "r") as f:
s3_paths = [line.strip() for line in f if line.strip()]
print(f"Found {len(s3_paths)} PDF paths in input list")
tests = []
for s3_path in tqdm(s3_paths, desc="Processing PDFs"):
process_pdf(s3_path, args.temp_dir, args.output_dir, api_key, tests)
if tests:
save_tests(tests, os.path.join(args.output_dir, "table_tests.jsonl"))
if len(tests) >= args.max_tests:
print(f"Reached maximum number of tests ({args.max_tests}), stopping")
break
print(f"Saved {len(tests)} table tests to {os.path.join(args.output_dir, 'table_tests.jsonl')}")
if __name__ == "__main__":
main()
@@ -0,0 +1,333 @@
#!/usr/bin/env python3
"""
mine_tables_gpt_simple.py - Identify PDF documents with tables and copy them.
This script:
1. Takes a file containing S3 paths to PDF documents as input
2. For each PDF, renders a random page and uses GPT-4o to check for tables
3. Identifies PDFs where the page contains a table
4. Copies those PDF files to a new output folder
Usage:
python mine_tables_gpt_simple.py --input_list path/to/s3_paths.txt --output_dir path/to/output --api_key your_openai_api_key
"""
import argparse
import os
import random
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
import boto3
import pypdf
from openai import OpenAI
from pydantic import BaseModel
from tqdm import tqdm
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.filter import PdfFilter
TARGET_IMAGE_DIM = 1024
class TableInfo(BaseModel):
"""Information about a single table."""
num_rows: int
num_cols: int
class TableDetectionResponse(BaseModel):
"""Structured output for table detection."""
tables: list[TableInfo]
def download_pdf_from_s3(s3_path: str, local_path: str) -> bool:
"""
Download a PDF file from S3.
Args:
s3_path: The S3 path (s3://bucket/path/to/file.pdf)
local_path: The local path to save the file
Returns:
bool: True if download was successful, False otherwise
"""
try:
# Parse S3 path
parts = s3_path.replace("s3://", "").split("/", 1)
bucket = parts[0]
key = parts[1]
# Create S3 client
s3 = boto3.client("s3")
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(local_path), exist_ok=True)
# Download file
s3.download_file(bucket, key, local_path)
return True
except Exception as e:
print(f"Error downloading {s3_path}: {str(e)}")
return False
def get_cell_count_bucket(total_cells: int) -> str:
"""
Get the folder name for a given cell count, bucketed by powers of 2.
Args:
total_cells: Total number of cells across all tables
Returns:
str: Folder name like "0_cells", "1_cell", "2_cells", "4_cells", etc.
"""
if total_cells == 0:
return "0_cells"
elif total_cells == 1:
return "1_cell"
else:
# Find the next power of 2 >= total_cells
power = 1
while power < total_cells:
power *= 2
return f"{power}_cells"
def check_for_table(pdf_path: str, page_num: int, api_key: str) -> Optional[tuple[bool, int]]:
"""
Use GPT-4o to check if a page contains a table.
Args:
pdf_path: Path to the PDF file
page_num: The page number to analyze (0-indexed)
api_key: OpenAI API key
Returns:
Optional[tuple[bool, int]]: Tuple of (has_table, total_cells) or None if detection fails
"""
# Initialize OpenAI client
client = OpenAI(api_key=api_key)
try:
# Render the PDF page as an image (render_pdf_to_base64png is 1-indexed)
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num + 1, target_longest_image_dim=TARGET_IMAGE_DIM)
# Prompt asking for detailed table information
prompt = "Identify all tables on this page. For each table, count the number of rows and columns. Return an empty list if there are no tables."
response = client.beta.chat.completions.parse(
model="gpt-5.1",
messages=[
{
"role": "user",
"content": [{"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}}],
}
],
max_completion_tokens=1000,
response_format=TableDetectionResponse,
)
if not response.choices or len(response.choices) == 0:
print(f"No response generated for {pdf_path} page {page_num}")
return None
# Parse the structured response
parsed_response = response.choices[0].message.parsed
if parsed_response is None:
print(f"Failed to parse response for {pdf_path} page {page_num}")
return None
tables = parsed_response.tables
has_table = len(tables) > 0
total_cells = sum(table.num_rows * table.num_cols for table in tables)
if has_table:
print(f"Found {len(tables)} table(s) in {pdf_path} page {page_num + 1}, total cells: {total_cells}")
for i, table in enumerate(tables, 1):
print(f" Table {i}: {table.num_rows} rows × {table.num_cols} cols = {table.num_rows * table.num_cols} cells")
return (has_table, total_cells)
except Exception as e:
print(f"Error checking {pdf_path} page {page_num}: {str(e)}")
return None
def process_pdf(s3_path: str, temp_dir: str, output_dir: str, api_key: str) -> bool:
"""
Process a single PDF from S3.
Args:
s3_path: S3 path to the PDF
temp_dir: Directory for temporary files
output_dir: Directory for output files
api_key: OpenAI API key
Returns:
bool: True if the PDF has a table and was copied, False otherwise
"""
# Extract filename from S3 path
pdf_filename = os.path.basename(s3_path)
local_pdf_path = os.path.join(temp_dir, pdf_filename)
# Download PDF from S3
if not download_pdf_from_s3(s3_path, local_pdf_path):
return False
pdf_filter = PdfFilter()
if pdf_filter.filter_out_pdf(local_pdf_path):
print(f"Filtering out {pdf_filename}")
return False
try:
# Read the PDF to get the number of pages
reader = pypdf.PdfReader(local_pdf_path)
num_pages = len(reader.pages)
if num_pages == 0:
print(f"PDF {pdf_filename} has no pages")
return False
# Select a random page to check
page_num = random.randint(0, num_pages - 1)
page_num = random.choice([page_num, 0]) # Bias 50% of the time to do the first page
# Check if the page contains a table
result = check_for_table(local_pdf_path, page_num, api_key)
if result is None:
return False
has_table, total_cells = result
if has_table:
# Get the cell count bucket for organizing output
bucket_name = get_cell_count_bucket(total_cells)
bucket_dir = os.path.join(output_dir, bucket_name)
os.makedirs(bucket_dir, exist_ok=True)
# Create output filename with basename_pgnum.pdf format
pdf_basename = os.path.splitext(pdf_filename)[0]
output_pdf_path = os.path.join(bucket_dir, f"{pdf_basename}_pg{page_num+1}.pdf")
# Extract the single page
writer = pypdf.PdfWriter()
writer.add_page(reader.pages[page_num])
# Write the output PDF
with open(output_pdf_path, "wb") as output_file:
writer.write(output_file)
print(f"Extracted page {page_num+1} with table from {pdf_filename} to {bucket_name}/{os.path.basename(output_pdf_path)}")
return True
return False
except Exception as e:
print(f"Error processing {pdf_filename}: {str(e)}")
return False
finally:
if os.path.exists(local_pdf_path):
os.remove(local_pdf_path)
def main():
parser = argparse.ArgumentParser(description="Identify and copy PDFs with tables")
parser.add_argument("--input_list", required=True, help="Path to a file containing S3 paths to PDFs")
parser.add_argument("--output_dir", required=True, help="Directory to copy PDFs with tables")
parser.add_argument("--api_key", help="OpenAI API key (if not provided, will use OPENAI_API_KEY environment variable)")
parser.add_argument("--temp_dir", default="/tmp/mine_tables", help="Directory for temporary files")
parser.add_argument("--max_pdfs", type=int, default=100, help="Maximum number of PDFs with tables to find")
parser.add_argument("--parallel", type=int, default=1, help="Number of parallel workers (default: 1 for sequential)")
parser.add_argument("--reservoir_multiplier", type=int, default=100, help="Multiplier for reservoir sampling (default: 100x max_pdfs)")
args = parser.parse_args()
# Get API key
api_key = args.api_key or os.environ.get("OPENAI_API_KEY")
if not api_key:
print("Error: OpenAI API key not provided. Use --api_key or set OPENAI_API_KEY environment variable.")
return
os.makedirs(args.temp_dir, exist_ok=True)
os.makedirs(args.output_dir, exist_ok=True)
# Reservoir sampling to get random subset of PDFs
reservoir_size = args.max_pdfs * args.reservoir_multiplier
pdf_paths = []
n = 0 # Total number of items seen
print(f"Using reservoir sampling with size {reservoir_size}")
with open(args.input_list, "r") as f:
for line in tqdm(f):
n += 1
path = line.strip()
if not path:
continue
if len(pdf_paths) < reservoir_size:
pdf_paths.append(path)
else:
# Randomly decide whether to include this item
s = random.randint(1, n)
if s <= reservoir_size:
pdf_paths[s - 1] = path
# Shuffle the reservoir
random.shuffle(pdf_paths)
print(f"Sampled {len(pdf_paths)} PDF paths from {n} total paths")
table_pdfs_found = 0
if args.parallel > 1:
# Parallel processing
print(f"Processing PDFs with {args.parallel} parallel workers")
with ThreadPoolExecutor(max_workers=args.parallel) as executor:
futures = []
# Submit all tasks
for s3_path in pdf_paths:
if table_pdfs_found >= args.max_pdfs:
break
future = executor.submit(process_pdf, s3_path, args.temp_dir, args.output_dir, api_key)
futures.append(future)
# Process results as they complete
with tqdm(total=min(len(pdf_paths), args.max_pdfs), desc="Processing PDFs") as pbar:
for future in as_completed(futures):
try:
result = future.result()
if result:
table_pdfs_found += 1
pbar.update(1)
if table_pdfs_found >= args.max_pdfs:
print(f"Reached maximum number of PDFs with tables ({args.max_pdfs}), stopping")
# Cancel remaining futures
for f in futures:
f.cancel()
break
except Exception as e:
print(f"Error in parallel processing: {str(e)}")
else:
# Sequential processing
for s3_path in tqdm(pdf_paths, desc="Processing PDFs"):
if process_pdf(s3_path, args.temp_dir, args.output_dir, api_key):
table_pdfs_found += 1
if table_pdfs_found >= args.max_pdfs:
print(f"Reached maximum number of PDFs with tables ({args.max_pdfs}), stopping")
break
print(f"Found and copied {table_pdfs_found} PDFs with tables to {args.output_dir}")
if __name__ == "__main__":
main()
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
pick_mediod.py - Identify representative examples from repeated OCR outputs
This code will take as arguments two directories:
--input and --output
Each of those is going to be a directory that was generated by convert.py and is a candidate to be evaluated as part of benchmark.py
What it will do is find and group all of the .md files into their repeats
ex. input_dir/tables/buildingnotes_pg1_repeat1.md, input_dir/tables/buildingnotes_pg1_repeat2.md, etc.
Then, for each repeat, it will use string similarity metrics to calculate the edit distance to every other repeat
The repeat with the lowest mean edit distance will then get output as ..._repeat1.md in the output folder
"""
import argparse
import glob
import os
import re
import shutil
from typing import Dict, List
from rapidfuzz import distance as fuzz_distance
from tqdm import tqdm
def compute_distance(text1: str, text2: str) -> float:
"""
Compute the edit distance between two text strings using rapidfuzz.
Returns a normalized distance between 0.0 (identical) and 1.0 (completely different).
"""
# Use Levenshtein distance for string comparison
return fuzz_distance.Levenshtein.normalized_distance(text1, text2)
def find_mediod(texts: List[str]) -> int:
"""
Find the index of the mediod from a list of texts.
The mediod is the text with the minimum average distance to all other texts.
"""
if not texts:
return -1
if len(texts) == 1:
return 0
# Calculate pairwise distances between all texts
n = len(texts)
distances = [[0.0 for _ in range(n)] for _ in range(n)]
for i in range(n):
for j in range(i + 1, n):
dist = compute_distance(texts[i], texts[j])
distances[i][j] = dist
distances[j][i] = dist
# Calculate average distance of each text to all others
avg_distances = []
for i in range(n):
avg_dist = sum(distances[i]) / (n - 1) # Don't include distance to self
avg_distances.append(avg_dist)
# Return the index of the text with the minimum average distance
min_avg_dist = min(avg_distances)
return avg_distances.index(min_avg_dist)
def group_repeats(md_files: List[str]) -> Dict[str, List[str]]:
"""
Group MD files by their base name (without the repeat number).
Returns a dictionary mapping base names to lists of file paths.
"""
grouped = {}
for md_path in md_files:
base_name = re.sub(r"_repeat\d+\.md$", "", os.path.basename(md_path))
if base_name not in grouped:
grouped[base_name] = []
grouped[base_name].append(md_path)
return grouped
def main():
parser = argparse.ArgumentParser(description="Find mediod (most representative) examples from repeated OCR outputs.")
parser.add_argument(
"--input", type=str, required=True, help="Path to the directory containing repeated OCR outputs (e.g., *_repeat1.md, *_repeat2.md, etc.)"
)
parser.add_argument("--output", type=str, required=True, help="Path to the directory where mediod examples will be copied")
parser.add_argument("--min_repeats", type=int, default=3, help="Minimum number of repeats required to compute a mediod (default: 3)")
args = parser.parse_args()
input_dir = args.input
output_dir = args.output
min_repeats = args.min_repeats
# Create output directory if it doesn't exist
os.makedirs(output_dir, exist_ok=True)
# Find all markdown files in the input directory (recursive)
md_files = glob.glob(os.path.join(input_dir, "**/*.md"), recursive=True)
if not md_files:
print(f"No markdown files found in {input_dir}")
return
# Group files by their base name
grouped_files = group_repeats(md_files)
# Process each group
successful = 0
skipped = 0
print(f"Found {len(grouped_files)} unique test cases with repeats")
for base_name, file_paths in tqdm(grouped_files.items(), desc="Processing test cases"):
# Skip if there aren't enough repeats
if len(file_paths) < min_repeats:
print(f"Skipping {base_name}: only {len(file_paths)} repeats (minimum {min_repeats} required)")
skipped += 1
continue
# Read all text content
texts = []
for path in file_paths:
try:
with open(path, "r", encoding="utf-8") as f:
texts.append(f.read())
except Exception as e:
print(f"Error reading {path}: {e}")
continue
# Find the mediod
mediod_idx = find_mediod(texts)
if mediod_idx == -1:
print(f"Failed to find mediod for {base_name}")
skipped += 1
continue
# Get the path of the mediod file
mediod_path = file_paths[mediod_idx]
# Create the output path, preserving the directory structure relative to input_dir
rel_path = os.path.relpath(mediod_path, input_dir)
# Change the repeat number to 1 in the output filename
output_filename = re.sub(r"_repeat\d+\.md$", "_repeat1.md", os.path.basename(rel_path))
output_subdir = os.path.dirname(rel_path)
output_path = os.path.join(output_dir, output_subdir, output_filename)
# Create directories if needed
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Copy the mediod file
try:
shutil.copy2(mediod_path, output_path)
successful += 1
except Exception as e:
print(f"Error copying {mediod_path} to {output_path}: {e}")
print(f"Processing complete: {successful} mediods copied, {skipped} cases skipped")
if __name__ == "__main__":
main()
+62
View File
@@ -0,0 +1,62 @@
def build_basic_prompt() -> str:
return "Please provide a natural, plain text representation of the document, formatted in Markdown. Skip any headers and footers. For ALL mathematical expressions, use LaTeX notation with \( and \) for inline equations and \[ and \] for display equations. Convert any tables into Markdown format."
def build_openai_silver_data_prompt_no_document_anchoring(_base_text: str) -> str:
return (
"Below is the image of one page of a PDF document. "
"Just return the plain text representation of this document as if you were reading it naturally.\n"
"Turn equations into a LaTeX representation, and tables into markdown format. Remove the headers and footers, but keep references and footnotes.\n"
"Read any natural handwriting.\n"
"This is likely one page out of several in the document, so be sure to preserve any sentences that come from the previous page, or continue onto the next page, exactly as they are.\n"
"If there is no text at all that you think you should read, you can output null.\n"
"Do not hallucinate."
)
def claude_response_format_schema() -> dict:
return (
{
"name": "page_response",
"description": "Extracts text from pdf's.",
"input_schema": {
"type": "object",
"properties": {
"primary_language": {
"type": ["string", "null"],
"description": "The primary language of the text using two-letter codes or null if there is no text at all that you think you should read.",
},
"is_rotation_valid": {
"type": "boolean",
"description": "Is this page oriented correctly for reading? Answer only considering the textual content, do not factor in the rotation of any charts, tables, drawings, or figures.",
},
"rotation_correction": {
"type": "integer",
"description": "Indicates the degree of clockwise rotation needed if the page is not oriented correctly.",
"enum": [0, 90, 180, 270],
"default": 0,
},
"is_table": {
"type": "boolean",
"description": "Indicates if the majority of the page content is in tabular format.",
},
"is_diagram": {
"type": "boolean",
"description": "Indicates if the majority of the page content is a visual diagram.",
},
"natural_text": {
"type": ["string", "null"],
"description": "The natural text content extracted from the page.",
},
},
"required": [
"primary_language",
"is_rotation_valid",
"rotation_correction",
"is_table",
"is_diagram",
"natural_text",
],
},
},
)
+300
View File
@@ -0,0 +1,300 @@
import glob
import os
from typing import Dict, List, Optional, Tuple
from tqdm import tqdm
from olmocr.data.renderpdf import render_pdf_to_base64webp
from .tests import BasePDFTest
def _filter_by_max_reports(
test_results_by_candidate: Dict[str, Dict[str, Dict[int, List[Tuple[BasePDFTest, bool, str]]]]],
test_to_jsonl: Dict[str, str],
max_reports: int,
) -> Dict[str, Dict[str, Dict[int, List[Tuple[BasePDFTest, bool, str]]]]]:
"""Filter test results to include at most max_reports unique PDFs per .jsonl file."""
filtered = {}
for candidate, pdf_results in test_results_by_candidate.items():
# Track which unique PDFs we've included per jsonl file
jsonl_pdfs: Dict[str, set] = {}
filtered_pdfs: Dict[str, Dict[int, List[Tuple[BasePDFTest, bool, str]]]] = {}
for pdf_name in sorted(pdf_results.keys()):
pages = pdf_results[pdf_name]
for page in sorted(pages.keys()):
for test_tuple in pages[page]:
test, passed, explanation = test_tuple
jsonl_file = test_to_jsonl.get(test.id, "unknown")
jsonl_pdfs.setdefault(jsonl_file, set())
# If this PDF is new for this jsonl file, check the limit
if pdf_name not in jsonl_pdfs[jsonl_file]:
if len(jsonl_pdfs[jsonl_file]) >= max_reports:
continue
jsonl_pdfs[jsonl_file].add(pdf_name)
filtered_pdfs.setdefault(pdf_name, {}).setdefault(page, []).append(test_tuple)
filtered[candidate] = filtered_pdfs
return filtered
def generate_html_report(
test_results_by_candidate: Dict[str, Dict[str, Dict[int, List[Tuple[BasePDFTest, bool, str]]]]],
pdf_folder: str,
output_file: str,
max_reports: Optional[int] = None,
test_to_jsonl: Optional[Dict[str, str]] = None,
) -> None:
"""
Generate a simple static HTML report of test results.
Args:
test_results_by_candidate: Dictionary mapping candidate name to dictionary mapping PDF name to dictionary
mapping page number to list of (test, passed, explanation) tuples.
pdf_folder: Path to the folder containing PDF files.
output_file: Path to the output HTML file.
max_reports: If set, limit to at most N tests per .jsonl file in the report.
test_to_jsonl: Dictionary mapping test IDs to their source jsonl filenames.
"""
# If max_reports is set, filter test_results_by_candidate to limit per jsonl file
if max_reports is not None and test_to_jsonl is not None:
test_results_by_candidate = _filter_by_max_reports(test_results_by_candidate, test_to_jsonl, max_reports)
candidates = list(test_results_by_candidate.keys())
# Create HTML report
html = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OLMOCR Bench Test Report</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
color: #333;
line-height: 1.6;
}
h1, h2, h3, h4 {
margin-top: 20px;
margin-bottom: 10px;
}
.test-block {
border: 1px solid #ddd;
margin-bottom: 30px;
padding: 15px;
border-radius: 5px;
}
.test-block.pass {
border-left: 5px solid #4CAF50;
}
.test-block.fail {
border-left: 5px solid #F44336;
}
.pdf-image {
max-width: 100%;
border: 1px solid #ddd;
margin: 10px 0;
}
.markdown-content {
background: #f5f5f5;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
font-family: monospace;
white-space: pre-wrap;
overflow-x: auto;
margin: 10px 0;
}
.markdown-content table {
border-collapse: collapse;
margin: 10px 0;
}
.markdown-content th,
.markdown-content td {
border: 1px solid #999;
padding: 4px 8px;
}
.markdown-content th {
background-color: #e0e0e0;
}
.status {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-weight: bold;
margin-left: 10px;
}
.pass-status {
background-color: #4CAF50;
color: white;
}
.fail-status {
background-color: #F44336;
color: white;
}
.test-details {
margin: 10px 0;
}
.test-explanation {
margin-top: 10px;
padding: 10px;
background: #fff9c4;
border-radius: 3px;
}
hr {
border: 0;
border-top: 1px solid #ddd;
margin: 30px 0;
}
</style>
</head>
<body>
<h1>OLMOCR Bench Test Report</h1>
"""
# Process all candidates
print("Generating test report...")
for candidate in candidates:
html += f"<h2>Candidate: {candidate}</h2>\n"
# Get all PDFs for this candidate
all_pdfs = sorted(test_results_by_candidate[candidate].keys())
for pdf_name in tqdm(all_pdfs, desc=f"Processing {candidate}"):
pages = sorted(test_results_by_candidate[candidate][pdf_name].keys())
for page in pages:
# Get tests for this PDF page
tests = test_results_by_candidate[candidate][pdf_name][page]
for test, passed, explanation in tests:
result_class = "pass" if passed else "fail"
status_text = "PASSED" if passed else "FAILED"
status_class = "pass-status" if passed else "fail-status"
# Begin test block
html += f"""
<div class="test-block {result_class}">
<h3>Test ID: {test.id} <span class="status {status_class}">{status_text}</span></h3>
<p><strong>PDF:</strong> {pdf_name} | <strong>Page:</strong> {page} | <strong>Type:</strong> {test.type}</p>
<div class="test-details">
"""
# Add test details based on type
test_type = getattr(test, "type", "").lower()
if test_type == "present" and hasattr(test, "text"):
text = getattr(test, "text", "")
html += f""" <p><strong>Text to find:</strong> "{text}"</p>\n"""
elif test_type == "absent" and hasattr(test, "text"):
text = getattr(test, "text", "")
html += f""" <p><strong>Text should not appear:</strong> "{text}"</p>\n"""
elif test_type == "order" and hasattr(test, "before") and hasattr(test, "after"):
before = getattr(test, "before", "")
after = getattr(test, "after", "")
html += f""" <p><strong>Text order:</strong> "{before}" should appear before "{after}"</p>\n"""
elif test_type == "table":
if hasattr(test, "cell"):
cell = getattr(test, "cell", "")
html += f""" <p><strong>Table cell:</strong> "{cell}"</p>\n"""
if hasattr(test, "up") and getattr(test, "up", None):
up = getattr(test, "up")
html += f""" <p><strong>Above:</strong> "{up}"</p>\n"""
if hasattr(test, "down") and getattr(test, "down", None):
down = getattr(test, "down")
html += f""" <p><strong>Below:</strong> "{down}"</p>\n"""
if hasattr(test, "left") and getattr(test, "left", None):
left = getattr(test, "left")
html += f""" <p><strong>Left:</strong> "{left}"</p>\n"""
if hasattr(test, "right") and getattr(test, "right", None):
right = getattr(test, "right")
html += f""" <p><strong>Right:</strong> "{right}"</p>\n"""
elif test_type == "math" and hasattr(test, "math"):
math = getattr(test, "math", "")
html += f""" <p><strong>Math equation:</strong> {math}</p>\n"""
elif test_type == "format" and hasattr(test, "text"):
text = getattr(test, "text", "")
fmt = getattr(test, "format", "")
html += f""" <p><strong>Text:</strong> "{text}" should be formatted as <strong>{fmt}</strong></p>\n"""
elif test_type == "footnote" and hasattr(test, "marker"):
marker = getattr(test, "marker", "")
html += f""" <p><strong>Footnote marker:</strong> {marker}</p>\n"""
before = getattr(test, "appears_before_marker", None)
after = getattr(test, "appears_after_marker", None)
if before:
html += f""" <p><strong>Text before marker:</strong> "{before}"</p>\n"""
if after:
html += f""" <p><strong>Text after marker:</strong> "{after}"</p>\n"""
elif test_type == "baseline":
max_length = getattr(test, "max_length", None)
if max_length is not None:
html += f""" <p><strong>Baseline check:</strong> max length {max_length} (blank page check)</p>\n"""
else:
html += f""" <p><strong>Baseline check:</strong> non-blank, no repeats, valid characters</p>\n"""
html += """ </div>\n"""
# Add explanation for failed tests
if not passed:
html += f""" <div class="test-explanation">
<strong>Explanation:</strong> {explanation}
</div>\n"""
# Render PDF page
pdf_path = os.path.join(pdf_folder, pdf_name)
try:
html += """ <h4>PDF Render:</h4>\n"""
image_data = render_pdf_to_base64webp(pdf_path, page, 1024)
html += f""" <img class="pdf-image" alt="PDF Page {page}" src="data:image/webp;base64,{image_data}" />\n"""
except Exception as e:
html += f""" <p>Error rendering PDF: {str(e)}</p>\n"""
# Get the Markdown content for this page
md_content = None
try:
md_base = os.path.splitext(pdf_name)[0]
md_files = list(glob.glob(os.path.join(os.path.dirname(pdf_folder), candidate, f"{md_base}_pg{page}_repeat*.md")))
if md_files:
md_file_path = md_files[0] # Use the first repeat as an example
with open(md_file_path, "r", encoding="utf-8") as f:
md_content = f.read()
except Exception as e:
md_content = f"Error loading Markdown content: {str(e)}"
if md_content:
html += """ <h4>Markdown Content:</h4>\n"""
html += f""" <div class="markdown-content">{md_content}</div>\n"""
# End test block
html += """ </div>\n"""
# Add separator between pages
html += """ <hr>\n"""
# Close HTML
html += """</body>
</html>
"""
with open(output_file, "w", encoding="utf-8") as f:
f.write(html)
print(f"Simple HTML report generated: {output_file}")
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python3
import argparse
import json
import os
import shutil
import sys
import tempfile
from collections import defaultdict
from typing import Dict, List, Optional, Tuple
from flask import Flask, jsonify, redirect, render_template, request, send_file, url_for
app = Flask(__name__)
# Add static folder for KaTeX files
app.static_folder = os.path.join(os.path.dirname(os.path.abspath(__file__)), "katex")
# Global state
DATASET_DIR = ""
DATASET_FILE = None
CURRENT_PDF = None
PDF_TESTS = {}
ALL_PDFS = []
FORCE = False # New global flag
def find_next_unchecked_pdf() -> Optional[str]:
"""Find the next PDF with at least one unchecked test."""
global PDF_TESTS, ALL_PDFS
for pdf_name in ALL_PDFS:
pdf_tests = PDF_TESTS[pdf_name]
for test in pdf_tests:
if test.get("checked") is None:
return pdf_name
return None
def calculate_stats() -> dict:
"""Calculate statistics for all tests in the dataset."""
global PDF_TESTS
total_tests = 0
null_status = 0
verified_status = 0
rejected_status = 0
for pdf_tests in PDF_TESTS.values():
total_tests += len(pdf_tests)
for test in pdf_tests:
status = test.get("checked")
if status is None:
null_status += 1
elif status == "verified":
verified_status += 1
elif status == "rejected":
rejected_status += 1
completion = 0
if total_tests > 0:
completion = (verified_status + rejected_status) / total_tests * 100
return {"total": total_tests, "null": null_status, "verified": verified_status, "rejected": rejected_status, "completion": completion}
def save_dataset(jsonl_file: str) -> None:
"""Save the tests to a JSONL file, using temp file for atomic write."""
global PDF_TESTS
# Flatten all tests
all_tests = []
for pdf_tests in PDF_TESTS.values():
all_tests.extend(pdf_tests)
# Create temp file and write updated content
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
for test in all_tests:
temp_file.write(json.dumps(test) + "\n")
# Atomic replace
shutil.move(temp_file.name, jsonl_file)
@app.route("/pdf/<path:pdf_name>")
def serve_pdf(pdf_name):
"""Serve the PDF file directly."""
pdf_path = os.path.join(DATASET_DIR, "pdfs", pdf_name)
return send_file(pdf_path, mimetype="application/pdf")
@app.route("/")
def index():
"""Main page displaying the current PDF and its tests."""
global CURRENT_PDF, PDF_TESTS, DATASET_DIR, ALL_PDFS, FORCE
# If no current PDF is set, find the next one with unchecked tests
if CURRENT_PDF is None:
CURRENT_PDF = find_next_unchecked_pdf()
# If still no PDF, either show the "All done" page or force display the first PDF
if CURRENT_PDF is None:
if FORCE and ALL_PDFS:
CURRENT_PDF = ALL_PDFS[0]
else:
return render_template("all_done.html")
# Get the tests for the current PDF
current_tests = PDF_TESTS.get(CURRENT_PDF, [])
# Create PDF URL for pdf.js to load
pdf_url = url_for("serve_pdf", pdf_name=CURRENT_PDF)
# Calculate statistics
stats = calculate_stats()
return render_template(
"review.html",
pdf_name=CURRENT_PDF,
tests=current_tests,
pdf_path=pdf_url,
pdf_index=ALL_PDFS.index(CURRENT_PDF) if CURRENT_PDF in ALL_PDFS else 0,
total_pdfs=len(ALL_PDFS),
stats=stats,
)
@app.route("/update_test", methods=["POST"])
def update_test():
"""API endpoint to update a test."""
global PDF_TESTS, DATASET_DIR, DATASET_FILE
data = request.json
pdf_name = data.get("pdf")
test_id = data.get("id")
field = data.get("field")
value = data.get("value")
# Find and update the test
for test in PDF_TESTS.get(pdf_name, []):
if test.get("id") == test_id:
test[field] = value
break
# Save the updated tests
save_dataset(DATASET_FILE)
return jsonify({"status": "success"})
@app.route("/reject_all", methods=["POST"])
def reject_all():
"""API endpoint to reject all tests for a PDF."""
global PDF_TESTS, DATASET_DIR, DATASET_FILE
data = request.json
pdf_name = data.get("pdf")
if pdf_name and pdf_name in PDF_TESTS:
# Update all tests for this PDF to rejected
for test in PDF_TESTS[pdf_name]:
test["checked"] = "rejected"
# Save the updated tests
save_dataset(DATASET_FILE)
return jsonify({"status": "success", "count": len(PDF_TESTS[pdf_name])})
return jsonify({"status": "error", "message": "PDF not found"})
@app.route("/next_pdf", methods=["POST"])
def next_pdf():
"""Move to the next PDF in the list."""
global CURRENT_PDF, ALL_PDFS, FORCE
if CURRENT_PDF in ALL_PDFS:
current_index = ALL_PDFS.index(CURRENT_PDF)
if current_index < len(ALL_PDFS) - 1:
CURRENT_PDF = ALL_PDFS[current_index + 1]
else:
# If in force mode, cycle back to the beginning instead of checking for an unchecked PDF
if FORCE and ALL_PDFS:
CURRENT_PDF = ALL_PDFS[0]
else:
CURRENT_PDF = find_next_unchecked_pdf()
else:
if FORCE and ALL_PDFS:
CURRENT_PDF = ALL_PDFS[0]
else:
CURRENT_PDF = find_next_unchecked_pdf()
return redirect(url_for("index"))
@app.route("/prev_pdf", methods=["POST"])
def prev_pdf():
"""Move to the previous PDF in the list."""
global CURRENT_PDF, ALL_PDFS
if CURRENT_PDF in ALL_PDFS:
current_index = ALL_PDFS.index(CURRENT_PDF)
if current_index > 0:
CURRENT_PDF = ALL_PDFS[current_index - 1]
return redirect(url_for("index"))
@app.route("/goto_pdf/<int:index>", methods=["POST"])
def goto_pdf(index):
"""Go to a specific PDF by index."""
global CURRENT_PDF, ALL_PDFS
if 0 <= index < len(ALL_PDFS):
CURRENT_PDF = ALL_PDFS[index]
return redirect(url_for("index"))
def load_dataset(dataset_file: str) -> Tuple[Dict[str, List[Dict]], List[str]]:
"""Load tests from the dataset file and organize them by PDF."""
if not os.path.exists(dataset_file):
raise FileNotFoundError(f"Dataset file not found: {dataset_file}")
pdf_tests = defaultdict(list)
with open(dataset_file, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
test = json.loads(line)
pdf_name = test.get("pdf")
if pdf_name:
pdf_tests[pdf_name].append(test)
except json.JSONDecodeError:
print(f"Warning: Could not parse line as JSON: {line}")
all_pdfs = list(pdf_tests.keys())
return pdf_tests, all_pdfs
def create_templates_directory():
"""Create templates directory for Flask if it doesn't exist."""
templates_dir = os.path.join(os.path.dirname(__file__), "templates")
os.makedirs(templates_dir, exist_ok=True)
def main():
"""Main entry point with command-line arguments."""
global DATASET_DIR, DATASET_FILE, PDF_TESTS, ALL_PDFS, CURRENT_PDF, FORCE
parser = argparse.ArgumentParser(description="Interactive Test Review App")
parser.add_argument("dataset_file", help="Path to the dataset jsonl file")
parser.add_argument("--port", type=int, default=5000, help="Port for the Flask app")
parser.add_argument("--host", default="127.0.0.1", help="Host for the Flask app")
parser.add_argument("--debug", action="store_true", help="Run Flask in debug mode")
parser.add_argument("--force", action="store_true", help="Force show each file one by one and never do the 'All done' page")
args = parser.parse_args()
FORCE = args.force # Set the global FORCE flag
# Validate dataset directory
if not os.path.exists(args.dataset_file):
print(f"Error: Dataset not found: {args.dataset_file}")
return 1
# Store dataset directory globally
DATASET_DIR = os.path.dirname(os.path.abspath(args.dataset_file))
DATASET_FILE = args.dataset_file
pdf_dir = os.path.join(DATASET_DIR, "pdfs")
if not os.path.isdir(pdf_dir):
print(f"Error: PDF directory not found: {pdf_dir}")
return 1
# Load dataset
try:
PDF_TESTS, ALL_PDFS = load_dataset(args.dataset_file)
except Exception as e:
print(f"Error loading dataset: {str(e)}")
return 1
# Create templates directory
create_templates_directory()
# Find first PDF with unchecked tests
CURRENT_PDF = find_next_unchecked_pdf()
# Start Flask app
print(f"Starting server at http://{args.host}:{args.port}")
app.run(host=args.host, port=args.port, debug=args.debug)
return 0
if __name__ == "__main__":
sys.exit(main())
+716
View File
@@ -0,0 +1,716 @@
#!/usr/bin/env python3
import argparse
import json
import os
import shutil
import sys
import tempfile
from collections import defaultdict
from typing import Dict, List, Optional, Tuple
from flask import Flask, jsonify, redirect, render_template, request, send_file, url_for
app = Flask(__name__)
# Global state
DATASET_DIR = ""
DATASET_FILE = None
CURRENT_PDF = None
PDF_TESTS = {}
ALL_PDFS = []
FORCE = False
def find_next_unchecked_pdf() -> Optional[str]:
"""Find the next PDF with at least one unchecked test."""
global PDF_TESTS, ALL_PDFS
for pdf_name in ALL_PDFS:
pdf_tests = PDF_TESTS[pdf_name]
for test in pdf_tests:
if test.get("checked") is None:
return pdf_name
return None
def calculate_stats() -> dict:
"""Calculate statistics for all tests in the dataset."""
global PDF_TESTS
total_tests = 0
null_status = 0
verified_status = 0
rejected_status = 0
for pdf_tests in PDF_TESTS.values():
total_tests += len(pdf_tests)
for test in pdf_tests:
status = test.get("checked")
if status is None:
null_status += 1
elif status == "verified":
verified_status += 1
elif status == "rejected":
rejected_status += 1
completion = 0
if total_tests > 0:
completion = (verified_status + rejected_status) / total_tests * 100
return {"total": total_tests, "null": null_status, "verified": verified_status, "rejected": rejected_status, "completion": completion}
def save_dataset(jsonl_file: str) -> None:
"""Save the tests to a JSONL file, using temp file for atomic write."""
global PDF_TESTS
# Flatten all tests
all_tests = []
for pdf_tests in PDF_TESTS.values():
all_tests.extend(pdf_tests)
# Create temp file and write updated content
with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_file:
for test in all_tests:
temp_file.write(json.dumps(test) + "\n")
# Atomic replace
shutil.move(temp_file.name, jsonl_file)
@app.route("/pdf/<path:pdf_name>")
def serve_pdf(pdf_name):
"""Serve the PDF file directly."""
pdf_path = os.path.join(DATASET_DIR, "pdfs", pdf_name)
return send_file(pdf_path, mimetype="application/pdf")
@app.route("/")
def index():
"""Main page displaying the current PDF and its tests."""
global CURRENT_PDF, PDF_TESTS, DATASET_DIR, ALL_PDFS, FORCE
# If no current PDF is set, find the next one with unchecked tests
if CURRENT_PDF is None:
CURRENT_PDF = find_next_unchecked_pdf()
# If still no PDF, either show the "All done" page or force display the first PDF
if CURRENT_PDF is None:
if FORCE and ALL_PDFS:
CURRENT_PDF = ALL_PDFS[0]
else:
return render_template("all_done_latex.html")
# Get the tests for the current PDF
current_tests = PDF_TESTS.get(CURRENT_PDF, [])
# Create PDF URL for pdf.js to load
pdf_url = url_for("serve_pdf", pdf_name=CURRENT_PDF)
# Calculate statistics
stats = calculate_stats()
return render_template(
"review_latex.html",
pdf_name=CURRENT_PDF,
tests=current_tests,
pdf_path=pdf_url,
pdf_index=ALL_PDFS.index(CURRENT_PDF) if CURRENT_PDF in ALL_PDFS else 0,
total_pdfs=len(ALL_PDFS),
stats=stats,
)
@app.route("/update_test", methods=["POST"])
def update_test():
"""API endpoint to update a test."""
global PDF_TESTS, DATASET_DIR, DATASET_FILE
data = request.json
pdf_name = data.get("pdf")
test_id = data.get("id")
field = data.get("field")
value = data.get("value")
# Find and update the test
for test in PDF_TESTS.get(pdf_name, []):
if test.get("id") == test_id:
test[field] = value
break
# Save the updated tests
save_dataset(DATASET_FILE)
return jsonify({"status": "success"})
@app.route("/reject_all", methods=["POST"])
def reject_all():
"""API endpoint to reject all tests for a PDF."""
global PDF_TESTS, DATASET_DIR, DATASET_FILE
data = request.json
pdf_name = data.get("pdf")
if pdf_name and pdf_name in PDF_TESTS:
# Update all tests for this PDF to rejected
for test in PDF_TESTS[pdf_name]:
test["checked"] = "rejected"
# Save the updated tests
save_dataset(DATASET_FILE)
return jsonify({"status": "success", "count": len(PDF_TESTS[pdf_name])})
return jsonify({"status": "error", "message": "PDF not found"})
@app.route("/next_pdf", methods=["POST"])
def next_pdf():
"""Move to the next PDF in the list."""
global CURRENT_PDF, ALL_PDFS, FORCE
if CURRENT_PDF in ALL_PDFS:
current_index = ALL_PDFS.index(CURRENT_PDF)
if current_index < len(ALL_PDFS) - 1:
CURRENT_PDF = ALL_PDFS[current_index + 1]
else:
# If in force mode, cycle back to the beginning instead of checking for an unchecked PDF
if FORCE and ALL_PDFS:
CURRENT_PDF = ALL_PDFS[0]
else:
CURRENT_PDF = find_next_unchecked_pdf()
else:
if FORCE and ALL_PDFS:
CURRENT_PDF = ALL_PDFS[0]
else:
CURRENT_PDF = find_next_unchecked_pdf()
return redirect(url_for("index"))
@app.route("/prev_pdf", methods=["POST"])
def prev_pdf():
"""Move to the previous PDF in the list."""
global CURRENT_PDF, ALL_PDFS
if CURRENT_PDF in ALL_PDFS:
current_index = ALL_PDFS.index(CURRENT_PDF)
if current_index > 0:
CURRENT_PDF = ALL_PDFS[current_index - 1]
return redirect(url_for("index"))
@app.route("/goto_pdf/<int:index>", methods=["POST"])
def goto_pdf(index):
"""Go to a specific PDF by index."""
global CURRENT_PDF, ALL_PDFS
if 0 <= index < len(ALL_PDFS):
CURRENT_PDF = ALL_PDFS[index]
return redirect(url_for("index"))
def load_dataset(dataset_file: str) -> Tuple[Dict[str, List[Dict]], List[str]]:
"""Load tests from the dataset file and organize them by PDF."""
if not os.path.exists(dataset_file):
raise FileNotFoundError(f"Dataset file not found: {dataset_file}")
pdf_tests = defaultdict(list)
with open(dataset_file, "r") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
test = json.loads(line)
pdf_name = test.get("pdf")
if pdf_name:
pdf_tests[pdf_name].append(test)
except json.JSONDecodeError:
print(f"Warning: Could not parse line as JSON: {line}")
all_pdfs = list(pdf_tests.keys())
return pdf_tests, all_pdfs
def create_templates_directory():
"""Create templates directory for Flask if it doesn't exist."""
templates_dir = os.path.join(os.path.dirname(__file__), "templates")
os.makedirs(templates_dir, exist_ok=True)
# Create the review_latex.html template with MathJax support
review_html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- You can adjust the viewport settings as needed -->
<meta name="viewport" content="width=1200, initial-scale=1.0">
<title>Equation Verification</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/3.2.2/es5/tex-mml-chtml.js"></script>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
height: 100vh;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background-color: #f0f0f0;
border-bottom: 1px solid #ddd;
}
.content {
display: flex;
flex: 1;
overflow: hidden;
}
.pdf-viewer {
flex: 2; /* Increased from 1 to 2 to make PDF larger */
border-right: 1px solid #ddd;
overflow: hidden;
position: relative;
}
/* Updated PDF container size */
#pdf-container {
width: 200%; /* New fixed width */
height: 200%; /* New fixed height */
overflow: auto;
}
#zoom-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 100;
background-color: white;
padding: 5px;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
#zoom-controls button {
margin: 0 5px;
padding: 5px 10px;
cursor: pointer;
}
.tests-panel {
width: 1000px;
overflow-y: auto;
padding: 10px;
}
.test-item {
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
margin-bottom: 10px;
transition: background-color 0.2s;
}
.test-item.verified {
background-color: #d4edda;
}
.test-item.rejected {
background-color: #f8d7da;
}
/* The equation-display now stores the raw LaTeX in a data attribute */
.equation-display {
padding: 10px;
margin: 5px 0;
border: 1px solid #ddd;
border-radius: 4px;
background-color: #f9f9f9;
overflow-x: auto;
font-size: 1.2em; /* Larger font for equations */
}
.button-group {
display: flex;
justify-content: space-between;
margin-top: 10px;
}
.button-group button {
padding: 5px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.verify-button {
background-color: #28a745;
color: white;
}
.reject-button {
background-color: #dc3545;
color: white;
}
.edit-button {
background-color: #007bff;
color: white;
}
.navigation {
display: flex;
align-items: center;
}
.status {
margin-left: 20px;
}
.progress-bar {
height: 10px;
background-color: #e9ecef;
border-radius: 5px;
margin-top: 5px;
overflow: hidden;
}
.progress {
height: 100%;
background-color: #007bff;
width: 0%;
}
/* Make MathJax equations more visible */
.MathJax {
font-size: 120% !important;
}
</style>
</head>
<body>
<div class="header">
<div>
<h2>PDF: {{ pdf_name }}</h2>
<form method="post" action="/reject_all" id="reject-all-form" style="display:inline;">
<button type="button" onclick="rejectAll('{{ pdf_name }}')">Reject All Equations</button>
</form>
</div>
<div class="navigation">
<form method="post" action="/prev_pdf" style="display:inline;">
<button type="submit" {% if pdf_index == 0 %}disabled{% endif %}>Previous</button>
</form>
<span style="margin: 0 10px;">{{ pdf_index + 1 }} / {{ total_pdfs }}</span>
<form method="post" action="/next_pdf" style="display:inline;">
<button type="submit">Next</button>
</form>
<div class="status">
<div>Completion: {{ "%.1f"|format(stats.completion) }}%</div>
<div class="progress-bar">
<div class="progress" style="width: {{ stats.completion }}%;"></div>
</div>
</div>
</div>
</div>
<div class="content">
<div class="pdf-viewer">
<div id="zoom-controls">
<button onclick="changeZoom(0.2)">+</button>
<button onclick="changeZoom(-0.2)">-</button>
<button onclick="resetZoom()">Reset</button>
</div>
<div id="pdf-container"></div>
</div>
<div class="tests-panel">
<h3>Equations ({{ tests|length }})</h3>
{% for test in tests %}
<!-- Added data-latex attribute to store raw LaTeX -->
<div class="test-item {% if test.checked == 'verified' %}verified{% elif test.checked == 'rejected' %}rejected{% endif %}" id="test-{{ test.id }}">
<div class="equation-display" data-latex="{{ test.text|e }}">
{{ test.text|safe }}
</div>
<div class="button-group">
<button class="verify-button" onclick="updateTest('{{ test.id }}', '{{ test.pdf }}', 'checked', 'verified')">Verify</button>
<button class="reject-button" onclick="updateTest('{{ test.id }}', '{{ test.pdf }}', 'checked', 'rejected')">Reject</button>
<!-- New Edit button -->
<button class="edit-button" onclick="enableEdit('{{ test.id }}', '{{ test.pdf }}')">Edit</button>
</div>
</div>
{% endfor %}
</div>
</div>
<script>
// Set up PDF.js
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js';
// Track current zoom level
let currentScale = 2.0; // Initial larger scale
let pdfDoc = null;
let pageNum = 1;
let canvas = null;
// Load the PDF
const loadingTask = pdfjsLib.getDocument('{{ pdf_path }}');
loadingTask.promise.then(function(pdf) {
pdfDoc = pdf;
renderPage(pageNum);
});
// Function to render a page with the current scale
function renderPage(num) {
pdfDoc.getPage(num).then(function(page) {
const viewport = page.getViewport({ scale: currentScale });
if (!canvas) {
canvas = document.createElement('canvas');
document.getElementById('pdf-container').appendChild(canvas);
}
const context = canvas.getContext('2d');
canvas.height = viewport.height;
canvas.width = viewport.width;
const renderContext = {
canvasContext: context,
viewport: viewport
};
page.render(renderContext);
});
}
// Function to change zoom level
function changeZoom(delta) {
currentScale += delta;
if (currentScale < 0.5) currentScale = 0.5;
if (currentScale > 5) currentScale = 5;
renderPage(pageNum);
}
// Function to reset zoom
function resetZoom() {
currentScale = 2.0;
renderPage(pageNum);
}
// Function to update a test used by both verify/reject and edit
function updateTest(testId, pdfName, field, value) {
fetch('/update_test', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
id: testId,
pdf: pdfName,
field: field,
value: value
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
const testElement = document.getElementById(`test-${testId}`);
testElement.classList.remove('verified', 'rejected');
// Only update the class if the field updated is "checked".
if (field === 'checked') {
testElement.classList.add(value);
}
}
});
}
// New function to enable editing the LaTeX equation
function enableEdit(testId, pdfName) {
let testElement = document.getElementById("test-" + testId);
let equationDisplay = testElement.querySelector(".equation-display");
// Retrieve the raw LaTeX from the data attribute
let rawLatex = equationDisplay.getAttribute('data-latex');
// Save the current rendered HTML in case of cancellation
let originalHTML = equationDisplay.innerHTML;
// Create a textarea for editing the LaTeX
let textarea = document.createElement("textarea");
textarea.id = "edit-input-" + testId;
// Use the stored raw LaTeX if available; otherwise, fallback to textContent
textarea.value = rawLatex ? rawLatex : equationDisplay.textContent.trim();
textarea.style.width = "100%";
textarea.rows = 3;
// Create Save button to commit changes
let saveButton = document.createElement("button");
saveButton.innerText = "Save";
saveButton.onclick = function() {
let newText = textarea.value;
// Update the test via AJAX updating the 'text' field
updateTest(testId, pdfName, "text", newText);
// Update the data-latex attribute to hold the new raw LaTeX code
equationDisplay.setAttribute('data-latex', newText);
// Replace the display content with the wrapped LaTeX for MathJax to process
equationDisplay.innerHTML = '$$' + newText + '$$';
if (typeof MathJax !== 'undefined') {
MathJax.typeset();
}
// Clean up the temporary editing elements
textarea.remove();
saveButton.remove();
cancelButton.remove();
};
// Create Cancel button to revert changes
let cancelButton = document.createElement("button");
cancelButton.innerText = "Cancel";
cancelButton.onclick = function() {
equationDisplay.innerHTML = originalHTML;
textarea.remove();
saveButton.remove();
cancelButton.remove();
};
// Show the editing interface: clear the display and insert the textarea
equationDisplay.innerHTML = "";
equationDisplay.appendChild(textarea);
// Append buttons to the test element (or you can choose to append them elsewhere)
testElement.appendChild(saveButton);
testElement.appendChild(cancelButton);
}
// Function to reject all tests for a PDF
function rejectAll(pdfName) {
if (confirm('Are you sure you want to reject all equations for this PDF?')) {
fetch('/reject_all', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
pdf: pdfName
})
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
const testElements = document.querySelectorAll('.test-item');
testElements.forEach(element => {
element.classList.remove('verified');
element.classList.add('rejected');
});
}
});
}
}
// Process LaTeX equations on page load: wrap plain text with $$ and trigger MathJax typesetting
document.addEventListener('DOMContentLoaded', function() {
const equationDisplays = document.querySelectorAll('.equation-display');
equationDisplays.forEach(display => {
let equation = display.textContent.trim();
if (!equation.startsWith('$$')) {
display.innerHTML = '$$' + equation + '$$';
}
});
if (typeof MathJax !== 'undefined') {
MathJax.typeset();
}
});
</script>
</body>
</html>
"""
# Create the all_done_latex.html template
all_done_html = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>All Done!</title>
<style>
body {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background-color: #f5f5f5;
}
.container {
text-align: center;
padding: 30px;
background-color: white;
border-radius: 10px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
h1 {
color: #28a745;
margin-bottom: 20px;
}
p {
font-size: 18px;
margin-bottom: 20px;
}
button {
padding: 10px 20px;
background-color: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
}
</style>
</head>
<body>
<div class="container">
<h1>All Done! 🎉</h1>
<p>You have reviewed all equations in the dataset.</p>
<form method="post" action="/next_pdf">
<button type="submit">Start Over</button>
</form>
</div>
</body>
</html>
"""
with open(os.path.join(templates_dir, "review_latex.html"), "w") as f:
f.write(review_html)
with open(os.path.join(templates_dir, "all_done_latex.html"), "w") as f:
f.write(all_done_html)
def main():
"""Main entry point with command-line arguments."""
global DATASET_DIR, DATASET_FILE, PDF_TESTS, ALL_PDFS, CURRENT_PDF, FORCE
parser = argparse.ArgumentParser(description="Interactive Test Review App")
parser.add_argument("dataset_file", help="Path to the dataset jsonl file")
parser.add_argument("--port", type=int, default=5000, help="Port for the Flask app")
parser.add_argument("--host", default="127.0.0.1", help="Host for the Flask app")
parser.add_argument("--debug", action="store_true", help="Run Flask in debug mode")
parser.add_argument("--force", action="store_true", help="Force show each file one by one and never do the 'All done' page")
args = parser.parse_args()
FORCE = args.force
if not os.path.exists(args.dataset_file):
print(f"Error: Dataset not found: {args.dataset_file}")
return 1
DATASET_DIR = os.path.dirname(os.path.abspath(args.dataset_file))
DATASET_FILE = args.dataset_file
pdf_dir = os.path.join(DATASET_DIR, "pdfs")
if not os.path.isdir(pdf_dir):
print(f"Error: PDF directory not found: {pdf_dir}")
return 1
try:
PDF_TESTS, ALL_PDFS = load_dataset(args.dataset_file)
except Exception as e:
print(f"Error loading dataset: {str(e)}")
return 1
create_templates_directory()
CURRENT_PDF = find_next_unchecked_pdf()
print(f"Starting server at http://{args.host}:{args.port}")
app.run(host=args.host, port=args.port, debug=args.debug)
return 0
if __name__ == "__main__":
sys.exit(main())
View File
+128
View File
@@ -0,0 +1,128 @@
import json
import logging
import os
from typing import Literal
from openai import OpenAI
from olmocr.bench.prompts import (
build_basic_prompt,
build_openai_silver_data_prompt_no_document_anchoring,
)
from olmocr.data.renderpdf import (
get_png_dimensions_from_base64,
render_pdf_to_base64png,
)
from olmocr.prompts.anchor import get_anchor_text
from olmocr.prompts.prompts import (
PageResponse,
build_finetuning_prompt,
build_openai_silver_data_prompt,
build_openai_silver_data_prompt_v2,
build_openai_silver_data_prompt_v2_simple,
build_openai_silver_data_prompt_v3_simple,
openai_response_format_schema,
)
# Set up logger
logger = logging.getLogger(__name__)
# Global variables to track token usage and document count
TOTAL_INPUT_TOKENS = 0
TOTAL_OUTPUT_TOKENS = 0
TOTAL_DOCUMENTS = 0
def run_chatgpt(
pdf_path: str,
page_num: int = 1,
model: str = "gpt-4o-2024-08-06",
temperature: float = 0.1,
target_longest_image_dim: int = 2048,
max_completion_tokens: int = 10000,
prompt_template: Literal["full", "full_no_document_anchoring", "basic", "finetune", "fullv2", "fullv2simple", "fullv3simple"] = "finetune",
response_template: Literal["plain", "json"] = "json",
) -> str:
"""
Convert page of a PDF file to markdown using the commercial openAI APIs.
See run_server.py for running against an openai compatible server
Args:
pdf_path (str): The local path to the PDF file.
Returns:
str: The OCR result in markdown format.
"""
global TOTAL_INPUT_TOKENS, TOTAL_OUTPUT_TOKENS, TOTAL_DOCUMENTS
# Convert the first page of the PDF to a base64-encoded PNG image.
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num, target_longest_image_dim=target_longest_image_dim)
anchor_text = get_anchor_text(pdf_path, page_num, pdf_engine="pdfreport")
if not os.getenv("OPENAI_API_KEY"):
raise SystemExit("You must specify an OPENAI_API_KEY")
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
if prompt_template == "full":
prompt = build_openai_silver_data_prompt(anchor_text)
elif prompt_template == "full_no_document_anchoring":
prompt = build_openai_silver_data_prompt_no_document_anchoring(anchor_text)
elif prompt_template == "finetune":
prompt = build_finetuning_prompt(anchor_text)
elif prompt_template == "basic":
prompt = build_basic_prompt()
elif prompt_template == "fullv2":
prompt = build_openai_silver_data_prompt_v2(anchor_text)
elif prompt_template == "fullv2simple":
width, height = get_png_dimensions_from_base64(image_base64)
prompt = build_openai_silver_data_prompt_v2_simple(width, height)
elif prompt_template == "fullv3simple":
width, height = get_png_dimensions_from_base64(image_base64)
prompt = build_openai_silver_data_prompt_v3_simple(width, height)
else:
raise ValueError("Unknown prompt template")
response = client.chat.completions.create(
model=model,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
],
}
],
temperature=temperature,
max_completion_tokens=max_completion_tokens,
# reasoning_effort="high",
response_format=openai_response_format_schema() if response_template == "json" else None,
safety_identifier="olmocr-bench-runner",
)
# Accumulate token counts from the response
if response.usage:
TOTAL_INPUT_TOKENS += response.usage.prompt_tokens
TOTAL_OUTPUT_TOKENS += response.usage.completion_tokens
# Increment document counter
TOTAL_DOCUMENTS += 1
raw_response = response.choices[0].message.content
assert len(response.choices) > 0
assert response.choices[0].message.refusal is None
assert response.choices[0].finish_reason == "stop"
if response_template == "json":
data = json.loads(raw_response)
data = PageResponse(**data)
# Log token counts before returning
logger.warning(f"Token Usage - Documents: {TOTAL_DOCUMENTS}, Input: {TOTAL_INPUT_TOKENS}, Output: {TOTAL_OUTPUT_TOKENS}")
return data.natural_text
else:
# Log token counts before returning
logger.warning(f"Token Usage - Documents: {TOTAL_DOCUMENTS}, Input: {TOTAL_INPUT_TOKENS}, Output: {TOTAL_OUTPUT_TOKENS}")
return raw_response
+61
View File
@@ -0,0 +1,61 @@
import json
import os
from anthropic import Anthropic
from prompts import build_openai_silver_data_prompt, claude_response_format_schema
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.prompts.anchor import get_anchor_text
def run_claude(pdf_path: str, page_num: int = 1, model: str = "claude-3-7-sonnet-20250219", temperature: float = 0.1) -> str:
"""
Convert page of a PDF file to markdown using Claude OCR.
This function renders the specified page of the PDF to an image, runs OCR on that image,
and returns the OCR result as a markdown-formatted string.
Args:
pdf_path (str): The local path to the PDF file.
page_num (int): The page number to process (starting from 1).
model (str): The Claude model to use.
temperature (float): The temperature parameter for generation.
Returns:
str: The OCR result in markdown format.
"""
if not os.getenv("ANTHROPIC_API_KEY"):
raise SystemExit("You must specify an ANTHROPIC_API_KEY")
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num, target_longest_image_dim=2048)
anchor_text = get_anchor_text(pdf_path, page_num, pdf_engine="pdfreport")
client = Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
response = client.messages.create(
model=model,
max_tokens=3000,
temperature=temperature,
# system=system_prompt,
tools=claude_response_format_schema(),
messages=[
{
"role": "user",
"content": [
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": image_base64}},
{
"type": "text",
"text": f"{build_openai_silver_data_prompt(anchor_text)}. Use the page_response tool to respond. If the propeties are true, then extract the text from them and respond in natural_text.",
},
],
}
],
)
json_sentiment = None
for content in response.content:
if content.type == "tool_use" and content.name == "page_response":
json_sentiment = content.input
break
if json_sentiment:
response = json.dumps(json_sentiment, indent=2)
return response
+76
View File
@@ -0,0 +1,76 @@
import asyncio
import os
import tempfile
from typing import Literal
from pypdf import PdfReader, PdfWriter
async def run_docling(
pdf_path: str,
page_num: int = 1,
output_format: Literal["markdown"] = "markdown",
use_smoldocling: bool = False,
) -> str:
"""Run docling CLI on a PDF file and return the results.
Args:
pdf_path: Path to the PDF file
page_num: Page number to process (1-indexed)
output_format: Output format (only markdown is supported for CLI version)
Returns:
String containing the markdown output
"""
if output_format != "markdown":
raise ValueError("Only markdown output format is supported for CLI version")
# Extract the specific page using pypdf
pdf_reader = PdfReader(pdf_path)
pdf_writer = PdfWriter()
# Convert from 1-indexed to 0-indexed
zero_based_page_num = page_num - 1
if zero_based_page_num >= len(pdf_reader.pages) or zero_based_page_num < 0:
raise ValueError(f"Page number {page_num} is out of bounds for PDF with {len(pdf_reader.pages)} pages")
# Add the selected page to the writer
pdf_writer.add_page(pdf_reader.pages[zero_based_page_num])
# Create temporary files for the single-page PDF and output markdown
with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp_pdf_file, tempfile.NamedTemporaryFile(suffix=".md", delete=False) as tmp_md_file:
tmp_pdf_path = tmp_pdf_file.name
tmp_md_path = tmp_md_file.name
try:
# Write the single-page PDF to the temporary file
with open(tmp_pdf_path, "wb") as f:
pdf_writer.write(f)
# Build the command to run docling on the single-page PDF
if use_smoldocling:
cmd = ["docling", tmp_pdf_path, "-o", tmp_md_path] # Output file
else:
cmd = ["docling", "--pipeline", "vlm", "--vlm-model", "smoldocling", tmp_pdf_path, "-o", tmp_md_path] # Output file
# Run the command asynchronously
proc = await asyncio.create_subprocess_exec(*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
raise RuntimeError(f"docling command failed with return code {proc.returncode}: {error_msg}")
# Read the results from the temporary markdown file
with open(tmp_md_path, "r", encoding="utf-8") as f:
result = f.read()
return result
finally:
# Clean up the temporary files
for path in [tmp_pdf_path, tmp_md_path]:
if os.path.exists(path):
os.unlink(path)
+190
View File
@@ -0,0 +1,190 @@
import asyncio
import os
import sys
import tempfile
from pathlib import Path
from typing import Dict, Optional, Tuple
from urllib.parse import urlparse
from pypdf import PdfReader, PdfWriter
REPO_ROOT = Path(__file__).resolve().parents[3]
DOTS_OCR_PATH = REPO_ROOT / "dots.ocr"
if not DOTS_OCR_PATH.exists():
raise ImportError(f"Could not find dots.ocr checkout at {DOTS_OCR_PATH}")
if str(DOTS_OCR_PATH) not in sys.path:
sys.path.insert(0, str(DOTS_OCR_PATH))
from dots_ocr.parser import DotsOCRParser # noqa: E402
_PARSER_CACHE: Dict[
Tuple[str, str, int, str, float, float, int, int, Optional[int], Optional[int], bool],
DotsOCRParser,
] = {}
def _parse_server(server: Optional[str]) -> Tuple[str, str, int]:
normalized = (server or "").strip() or "http://localhost:8000"
if "://" not in normalized:
normalized = f"http://{normalized}"
parsed = urlparse(normalized)
if not parsed.scheme or not parsed.hostname:
raise ValueError(f"Invalid server URL: {server}")
port = parsed.port
if port is None:
port = 443 if parsed.scheme == "https" else 80
return parsed.scheme, parsed.hostname, port
def _get_parser(
*,
protocol: str,
ip: str,
port: int,
model_name: str,
temperature: float,
top_p: float,
max_completion_tokens: int,
num_thread: int,
dpi: int,
min_pixels: Optional[int],
max_pixels: Optional[int],
use_hf: bool,
) -> DotsOCRParser:
cache_key = (
protocol,
ip,
port,
model_name,
temperature,
top_p,
max_completion_tokens,
num_thread,
dpi,
min_pixels,
max_pixels,
use_hf,
)
parser = _PARSER_CACHE.get(cache_key)
if parser is None:
parser = DotsOCRParser(
protocol=protocol,
ip=ip,
port=port,
model_name=model_name,
temperature=temperature,
top_p=top_p,
max_completion_tokens=max_completion_tokens,
num_thread=num_thread,
dpi=dpi,
output_dir=tempfile.mkdtemp(prefix="dotsocr-cache-"),
min_pixels=min_pixels,
max_pixels=max_pixels,
use_hf=use_hf,
)
_PARSER_CACHE[cache_key] = parser
return parser
def _extract_markdown_from_pdf(
parser: DotsOCRParser,
pdf_path: str,
page_num: int,
prompt_mode: str,
fitz_preprocess: bool,
) -> str:
if page_num < 1:
raise ValueError("page_num must be >= 1")
with open(pdf_path, "rb") as pdf_file:
reader = PdfReader(pdf_file)
zero_index = page_num - 1
if zero_index >= len(reader.pages):
raise ValueError(f"Page {page_num} does not exist in {pdf_path}")
writer = PdfWriter()
writer.add_page(reader.pages[zero_index])
with tempfile.TemporaryDirectory(prefix="dotsocr-") as tmp_dir:
single_page_pdf = os.path.join(tmp_dir, "page.pdf")
with open(single_page_pdf, "wb") as tmp_pdf:
writer.write(tmp_pdf)
output_dir = os.path.join(tmp_dir, "output")
os.makedirs(output_dir, exist_ok=True)
results = parser.parse_file(
single_page_pdf,
output_dir=output_dir,
prompt_mode=prompt_mode,
fitz_preprocess=fitz_preprocess,
)
if not results:
raise RuntimeError("DotsOCR did not return any results")
# Single-page PDF means the first result corresponds to our request.
page_result = results[0]
md_path = page_result.get("md_content_path") or page_result.get("md_content_nohf_path")
if not md_path:
raise RuntimeError(f"DotsOCR (prompt_mode={prompt_mode}) did not produce markdown output for page {page_num}")
if not os.path.exists(md_path):
raise RuntimeError(f"DotsOCR reported markdown at '{md_path}', but the file does not exist")
with open(md_path, "r", encoding="utf-8") as md_file:
return md_file.read()
async def run_dotsocr(
pdf_path: str,
page_num: int = 1,
server: Optional[str] = "http://localhost:8000",
prompt_mode: str = "prompt_layout_all_en",
model_name: str = "rednote-hilab/dots.ocr",
temperature: float = 0.1,
top_p: float = 1.0,
max_completion_tokens: int = 16384,
num_thread: int = 16,
dpi: int = 200,
min_pixels: Optional[int] = None,
max_pixels: Optional[int] = None,
use_hf: bool = False,
fitz_preprocess: bool = False,
) -> str:
"""
Run DotsOCR on a single PDF page and return the markdown output.
"""
protocol, ip, port = _parse_server(server)
parser = _get_parser(
protocol=protocol,
ip=ip,
port=port,
model_name=model_name,
temperature=temperature,
top_p=top_p,
max_completion_tokens=max_completion_tokens,
num_thread=num_thread,
dpi=dpi,
min_pixels=min_pixels,
max_pixels=max_pixels,
use_hf=use_hf,
)
return await asyncio.to_thread(
_extract_markdown_from_pdf,
parser,
pdf_path,
page_num,
prompt_mode,
fitz_preprocess,
)
+163
View File
@@ -0,0 +1,163 @@
import base64
import json
import logging
import os
from typing import Literal
from google import genai
from google.genai import types
from olmocr.bench.prompts import (
build_openai_silver_data_prompt_no_document_anchoring,
)
from olmocr.data.renderpdf import (
get_png_dimensions_from_base64,
render_pdf_to_base64png,
)
from olmocr.prompts.anchor import get_anchor_text
from olmocr.prompts.prompts import (
build_openai_silver_data_prompt,
build_openai_silver_data_prompt_v3_simple,
)
# Set up logger
logger = logging.getLogger(__name__)
# Global variables to track token usage and documents
TOTAL_INPUT_TOKENS = 0
TOTAL_OUTPUT_TOKENS = 0
TOTAL_DOCUMENTS = 0
def run_gemini(
pdf_path: str,
page_num: int = 1,
model: str = "gemini-2.0-flash",
temperature: float = 0.1,
target_longest_image_dim: int = 2048,
prompt_template: Literal["full", "full_no_document_anchoring", "basic", "finetune", "fullv3simple"] = "finetune",
response_template: Literal["plain", "json"] = "json",
) -> str:
"""
Convert page of a PDF file to markdown using Gemini's vision capabilities.
This function renders the specified page of the PDF to an image, runs OCR on that image,
and returns the OCR result as a markdown-formatted string.
Args:
pdf_path (str): The local path to the PDF file.
page_num (int): The page number to process (starting from 1).
model (str): The Gemini model to use.
temperature (float): The temperature parameter for generation.
Returns:
str: The OCR result in markdown format.
"""
global TOTAL_INPUT_TOKENS, TOTAL_OUTPUT_TOKENS, TOTAL_DOCUMENTS
TOTAL_DOCUMENTS += 1
if not os.getenv("GEMINI_API_KEY"):
raise SystemExit("You must specify an GEMINI_API_KEY")
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num, target_longest_image_dim=2048)
anchor_text = get_anchor_text(pdf_path, page_num, pdf_engine="pdfreport")
api_key = os.getenv("GEMINI_API_KEY")
client = genai.Client(api_key=api_key)
image_part = types.Part(inline_data=types.Blob(mime_type="image/png", data=base64.b64decode(image_base64)))
if prompt_template == "full":
text_part = types.Part(text=f"""{build_openai_silver_data_prompt(anchor_text)}""")
elif prompt_template == "full_no_document_anchoring":
text_part = types.Part(text=f"""{build_openai_silver_data_prompt_no_document_anchoring(anchor_text)}""")
elif prompt_template == "fullv3simple":
width, height = get_png_dimensions_from_base64(image_base64)
prompt = build_openai_silver_data_prompt_v3_simple(width, height)
text_part = types.Part(text=prompt)
else:
raise NotImplementedError()
if response_template == "json":
generation_config = types.GenerateContentConfig(
temperature=temperature,
top_p=1.0,
top_k=32,
max_output_tokens=10000,
response_mime_type="application/json",
response_schema=genai.types.Schema(
type=genai.types.Type.OBJECT,
required=["primary_language", "is_rotation_valid", "rotation_correction", "is_table", "is_diagram", "natural_text"],
properties={
"primary_language": genai.types.Schema(
type=genai.types.Type.STRING,
),
"is_rotation_valid": genai.types.Schema(
type=genai.types.Type.BOOLEAN,
),
"rotation_correction": genai.types.Schema(
type=genai.types.Type.STRING,
enum=["0", "90", "180", "270"],
),
"is_table": genai.types.Schema(
type=genai.types.Type.BOOLEAN,
),
"is_diagram": genai.types.Schema(
type=genai.types.Type.BOOLEAN,
),
"natural_text": genai.types.Schema(
type=genai.types.Type.STRING,
),
},
),
)
response = client.models.generate_content(
model=f"models/{model}",
contents=[types.Content(parts=[image_part, text_part])],
config=generation_config,
)
assert len(response.candidates) > 0, "No candidates found"
assert response.candidates[0].finish_reason == types.FinishReason.STOP, "Finish reason was not STOP, likely a processing error or repetition failure"
# Extract token counts from usage metadata
if hasattr(response, "usage_metadata"):
input_tokens = getattr(response.usage_metadata, "prompt_token_count", 0)
output_tokens = getattr(response.usage_metadata, "candidates_token_count", 0)
TOTAL_INPUT_TOKENS += input_tokens
TOTAL_OUTPUT_TOKENS += output_tokens
result = response.candidates[0].content.parts[0].text
parsed = json.loads(result)
# The json schema is slightly off with gemini vs chatgpt, so we don't verify it
logger.warning(
f"[Before Return - JSON] Total Documents: {TOTAL_DOCUMENTS}, Total Input Tokens: {TOTAL_INPUT_TOKENS}, Total Output Tokens: {TOTAL_OUTPUT_TOKENS}"
)
return parsed["natural_text"]
else:
generation_config = types.GenerateContentConfig(
temperature=temperature,
top_p=1.0,
top_k=32,
max_output_tokens=4096,
)
response = client.models.generate_content(
model=f"models/{model}",
contents=[types.Content(parts=[image_part, text_part])],
config=generation_config,
)
assert len(response.candidates) > 0, "No candidates found"
assert response.candidates[0].finish_reason == types.FinishReason.STOP, "Finish reason was not STOP, likely a processing error or repetition failure"
# Extract token counts from usage metadata
if hasattr(response, "usage_metadata"):
input_tokens = getattr(response.usage_metadata, "prompt_token_count", 0)
output_tokens = getattr(response.usage_metadata, "candidates_token_count", 0)
TOTAL_INPUT_TOKENS += input_tokens
TOTAL_OUTPUT_TOKENS += output_tokens
result = response.candidates[0].content.parts[0].text
logger.warning(
f"[Before Return - Plain] Total Documents: {TOTAL_DOCUMENTS}, Total Input Tokens: {TOTAL_INPUT_TOKENS}, Total Output Tokens: {TOTAL_OUTPUT_TOKENS}"
)
return result
+67
View File
@@ -0,0 +1,67 @@
import base64
import os
import tempfile
import torch
from transformers import AutoModel, AutoTokenizer
from olmocr.data.renderpdf import render_pdf_to_base64png
# Global cache for the model and tokenizer.
_device = "cuda" if torch.cuda.is_available() else "cpu"
_model = None
_tokenizer = None
def load_model():
"""
Load the GOT-OCR model and tokenizer if they haven't been loaded already.
Returns:
model: The GOT-OCR model loaded on the appropriate device.
tokenizer: The corresponding tokenizer.
"""
global _model, _tokenizer
if _model is None or _tokenizer is None:
_tokenizer = AutoTokenizer.from_pretrained("ucaslcl/GOT-OCR2_0", trust_remote_code=True)
_model = AutoModel.from_pretrained(
"ucaslcl/GOT-OCR2_0",
trust_remote_code=True,
use_safetensors=True,
revision="979938bf89ccdc949c0131ddd3841e24578a4742",
pad_token_id=_tokenizer.eos_token_id,
)
_model = _model.eval().to(_device)
return _model, _tokenizer
def run_gotocr(pdf_path: str, page_num: int = 1, ocr_type: str = "ocr") -> str:
"""
Convert page of a PDF file to markdown using GOT-OCR.
This function renders the first page of the PDF to an image, runs OCR on that image,
and returns the OCR result as a markdown-formatted string.
Args:
pdf_path (str): The local path to the PDF file.
Returns:
str: The OCR result in markdown format.
"""
# Ensure the model is loaded (cached across calls)
model, tokenizer = load_model()
# Convert the first page of the PDF to a base64-encoded PNG image.
base64image = render_pdf_to_base64png(pdf_path, page_num=page_num, target_longest_image_dim=1024)
# Write the image to a temporary file.
with tempfile.NamedTemporaryFile("wb", suffix=".png", delete=False) as tmp:
tmp.write(base64.b64decode(base64image))
tmp_filename = tmp.name
# Run GOT-OCR on the saved image.
result = model.chat(tokenizer, tmp_filename, ocr_type=ocr_type)
# Clean up the temporary file.
os.remove(tmp_filename)
return result
+69
View File
@@ -0,0 +1,69 @@
import os
import tempfile
from marker.config.parser import ConfigParser
from marker.converters.pdf import PdfConverter
from marker.models import create_model_dict
from marker.output import text_from_rendered
from pypdf import PdfReader, PdfWriter
_marker_converter = None
def run_marker(pdf_path: str, page_num: int = 1) -> str:
global _marker_converter
if _marker_converter is None:
# Create a configuration dictionary with the necessary settings
config = {
"force_ocr": True, # This enables conversion of inline math to LaTeX
"use_llm": False, # We would prefer to run just plain marker for reporting bench results, not hybrid mode
"disable_tqdm": True, # Disable tqdm for cleaner output
"recognition_batch_size": 256,
"layout_batch_size": 48,
"detection_batch_size": 48,
"equation_batch_size": 64,
"table_rec_batch_size": 48,
"ocr_error_batch_size": 64,
}
config_parser = ConfigParser(config)
_marker_converter = PdfConverter(
artifact_dict=create_model_dict(),
config=config_parser.generate_config_dict(),
)
# Extract the specific page from the PDF
pdf_to_process = pdf_path
temp_file = None
if page_num > 0: # If a specific page is requested
reader = PdfReader(pdf_path)
# Check if the requested page exists
if page_num > len(reader.pages):
raise ValueError(f"Page {page_num} does not exist in the PDF. PDF has {len(reader.pages)} pages.")
# Create a new PDF with just the requested page
writer = PdfWriter()
# pypdf uses 0-based indexing, so subtract 1 from page_num
writer.add_page(reader.pages[page_num - 1])
# Save the extracted page to a temporary file
temp_file = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
temp_file.close() # Close the file but keep the name
with open(temp_file.name, "wb") as output_pdf:
writer.write(output_pdf)
pdf_to_process = temp_file.name
try:
# Process the PDF (either original or single-page extract)
rendered = _marker_converter(pdf_to_process)
text, _, images = text_from_rendered(rendered)
return text
finally:
# Clean up the temporary file if it was created
if temp_file and os.path.exists(temp_file.name):
os.unlink(temp_file.name)
+74
View File
@@ -0,0 +1,74 @@
import os
import tempfile
from magic_pdf.config.enums import SupportedPdfParseMethod
from magic_pdf.data.data_reader_writer import FileBasedDataReader, FileBasedDataWriter
from magic_pdf.data.dataset import PymuDocDataset
from magic_pdf.model.doc_analyze_by_custom_model import doc_analyze
from pypdf import PdfReader, PdfWriter
def run_mineru(pdf_path: str, page_num: int = 1) -> str:
output_folder = tempfile.TemporaryDirectory()
image_output_folder = tempfile.TemporaryDirectory()
# Initialize writers (same for all PDFs)
image_writer = FileBasedDataWriter(image_output_folder.name)
md_writer = FileBasedDataWriter(output_folder.name)
if page_num > 0: # If a specific page is requested
reader = PdfReader(pdf_path)
# Check if the requested page exists
if page_num > len(reader.pages):
raise ValueError(f"Page {page_num} does not exist in the PDF. PDF has {len(reader.pages)} pages.")
# Create a new PDF with just the requested page
writer = PdfWriter()
# pypdf uses 0-based indexing, so subtract 1 from page_num
writer.add_page(reader.pages[page_num - 1])
# Save the extracted page to a temporary file
temp_file = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
temp_file.close() # Close the file but keep the name
with open(temp_file.name, "wb") as output_pdf:
writer.write(output_pdf)
pdf_to_process = temp_file.name
else:
pdf_to_process = pdf_path
try:
# Read the PDF file bytes
reader = FileBasedDataReader("")
pdf_bytes = reader.read(pdf_to_process)
# Create dataset instance
ds = PymuDocDataset(pdf_bytes)
# Inference: decide whether to run OCR mode based on dataset classification
if ds.classify() == SupportedPdfParseMethod.OCR:
infer_result = ds.apply(doc_analyze, ocr=True)
pipe_result = infer_result.pipe_ocr_mode(image_writer)
else:
infer_result = ds.apply(doc_analyze, ocr=False)
pipe_result = infer_result.pipe_txt_mode(image_writer)
# Generate markdown content; the image directory is the basename of the images output folder
image_dir_basename = os.path.basename(image_output_folder.name)
# md_content = pipe_result.get_markdown(image_dir_basename)
# Dump markdown file
with tempfile.NamedTemporaryFile("w+", suffix="md") as tf:
pipe_result.dump_md(md_writer, tf.name, image_dir_basename)
tf.flush()
tf.seek(0)
md_data = tf.read()
return md_data
finally:
# Clean up the temporary file if it was created
if temp_file and os.path.exists(temp_file.name):
os.unlink(temp_file.name)
+74
View File
@@ -0,0 +1,74 @@
import os
import tempfile
from mistralai import Mistral
from pypdf import PdfReader, PdfWriter
def run_mistral(pdf_path: str, page_num: int = 1) -> str:
"""
Convert page of a PDF file to markdown using the mistral OCR api
https://docs.mistral.ai/capabilities/document/
Args:
pdf_path (str): The local path to the PDF file.
Returns:
str: The OCR result in markdown format.
"""
if not os.getenv("MISTRAL_API_KEY"):
raise SystemExit("You must specify an MISTRAL_API_KEY")
api_key = os.environ["MISTRAL_API_KEY"]
client = Mistral(api_key=api_key)
if page_num > 0: # If a specific page is requested
reader = PdfReader(pdf_path)
# Check if the requested page exists
if page_num > len(reader.pages):
raise ValueError(f"Page {page_num} does not exist in the PDF. PDF has {len(reader.pages)} pages.")
# Create a new PDF with just the requested page
writer = PdfWriter()
# pypdf uses 0-based indexing, so subtract 1 from page_num
writer.add_page(reader.pages[page_num - 1])
# Save the extracted page to a temporary file
temp_file = tempfile.NamedTemporaryFile(suffix=".pdf", delete=False)
temp_file.close() # Close the file but keep the name
with open(temp_file.name, "wb") as output_pdf:
writer.write(output_pdf)
pdf_to_process = temp_file.name
else:
pdf_to_process = pdf_path
try:
with open(pdf_to_process, "rb") as pf:
uploaded_pdf = client.files.upload(
file={
"file_name": os.path.basename(pdf_path),
"content": pf,
},
purpose="ocr",
)
signed_url = client.files.get_signed_url(file_id=uploaded_pdf.id)
ocr_response = client.ocr.process(
model="mistral-ocr-2503",
document={
"type": "document_url",
"document_url": signed_url.url,
},
)
client.files.delete(file_id=uploaded_pdf.id)
return ocr_response.pages[0].markdown
finally:
# Clean up the temporary file if it was created
if temp_file and os.path.exists(temp_file.name):
os.unlink(temp_file.name)
+89
View File
@@ -0,0 +1,89 @@
import base64
import os
import re
import tempfile
import torch
from PIL import Image
from transformers import AutoModelForImageTextToText, AutoProcessor, AutoTokenizer
from olmocr.data.renderpdf import render_pdf_to_base64png
_model = None
_tokenizer = None
_processor = None
_device = None
def load_model(model_path: str = "nanonets/Nanonets-OCR-s"):
global _model, _tokenizer, _processor, _device
if _model is None:
_device = "cuda" if torch.cuda.is_available() else "cpu"
_model = AutoModelForImageTextToText.from_pretrained(
model_path,
torch_dtype="auto",
device_map="auto",
# attn_implementation="flash_attention_2"
)
_model.eval()
_tokenizer = AutoTokenizer.from_pretrained(model_path)
_processor = AutoProcessor.from_pretrained(model_path)
return _model, _tokenizer, _processor
async def run_nanonetsocr(pdf_path: str, page_num: int = 1, model_path: str = "nanonets/Nanonets-OCR-s", max_new_tokens: int = 4096, **kwargs) -> str:
"""
Convert page of a PDF file to markdown using NANONETS-OCR.
This function renders the first page of the PDF to an image, runs OCR on that image,
and returns the OCR result as a markdown-formatted string.
Args:
pdf_path (str): The local path to the PDF file.
Returns:
str: The OCR result in markdown format.
"""
model, tokenizer, processor = load_model(model_path)
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num, target_longest_image_dim=1024)
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as temp_file:
image_data = base64.b64decode(image_base64)
temp_file.write(image_data)
temp_image_path = temp_file.name
try:
image = Image.open(temp_image_path)
prompt = """Extract the text from the above document as if you were reading it naturally. Return the tables in html format. Return the equations in LaTeX representation. If there is an image in the document and image caption is not present, add a small description of the image inside the <img></img> tag; otherwise, add the image caption inside <img></img>. Watermarks should be wrapped in brackets. Ex: <watermark>OFFICIAL COPY</watermark>. Page numbers should be wrapped in brackets. Ex: <page_number>14</page_number> or <page_number>9/22</page_number>. Prefer using ☐ and ☑ for check boxes."""
messages = [
{"role": "system", "content": "You are a helpful assistant."},
{
"role": "user",
"content": [
{"type": "image", "image": f"file://{temp_image_path}"},
{"type": "text", "text": prompt},
],
},
]
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
inputs = processor(text=[text], images=[image], padding=True, return_tensors="pt", use_fast=True)
inputs = inputs.to(model.device)
with torch.no_grad():
output_ids = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
generated_ids = [output_ids[len(input_ids) :] for input_ids, output_ids in zip(inputs.input_ids, output_ids)]
output_text = processor.batch_decode(generated_ids, skip_special_tokens=True, clean_up_tokenization_spaces=True)
cleaned_text = re.sub(r"<page_number>\d+</page_number>", "", output_text[0])
return cleaned_text
finally:
try:
os.unlink(temp_image_path)
except Exception as e:
print(f"Warning: Failed to remove temporary file {temp_image_path}: {e}")
+86
View File
@@ -0,0 +1,86 @@
import base64
import subprocess
import httpx
from olmocr.data.renderpdf import get_pdf_media_box_width_height
# Logic to set min size from here: https://github.com/NanoNets/Nanonets-OCR2/blob/main/Nanonets-OCR2-Cookbook/image2md.ipynb
def render_pdf_to_base64png_min_short_size(local_pdf_path: str, page_num: int, target_shortest_dim: int = 2048) -> str:
shortest_dim = min(get_pdf_media_box_width_height(local_pdf_path, page_num))
# Convert PDF page to PNG using pdftoppm
pdftoppm_result = subprocess.run(
[
"pdftoppm",
"-png",
"-f",
str(page_num),
"-l",
str(page_num),
"-r",
str(target_shortest_dim * 72 / shortest_dim), # 72 pixels per point is the conversion factor
local_pdf_path,
],
timeout=120,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
assert pdftoppm_result.returncode == 0, pdftoppm_result.stderr
return base64.b64encode(pdftoppm_result.stdout).decode("utf-8")
async def run_server(
pdf_path: str,
page_num: int = 1,
server: str = "localhost:30000",
model: str = "nanonets/Nanonets-OCR2-3B",
temperature: float = 0.0,
page_dimensions: int = 1280,
) -> str:
"""
Convert page of a PDF file to markdown by calling a request
running against an openai compatible server.
You can use this for running against vllm, sglang, servers
as well as mixing and matching different model's.
It will only make one direct request, with no retries or error checking.
Returns:
str: The OCR result in markdown format.
"""
# Convert the first page of the PDF to a base64-encoded PNG image.
image_base64 = render_pdf_to_base64png_min_short_size(pdf_path, page_num=page_num, target_shortest_dim=page_dimensions)
# Now use th
prompt = """Extract the text from the above document as if you were reading it naturally. Return the tables in html format. Return the equations in LaTeX representation. If there is an image in the document and image caption is not present, add a small description of the image inside the <img></img> tag; otherwise, add the image caption inside <img></img>. Watermarks should be wrapped in brackets. Ex: <watermark>OFFICIAL COPY</watermark>. Page numbers should be wrapped in brackets. Ex: <page_number>14</page_number> or <page_number>9/22</page_number>. Prefer using ☐ and ☑ for check boxes."""
request = {
"model": model,
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
{"type": "text", "text": prompt},
],
},
],
"temperature": temperature,
"max_tokens": 4096,
}
# Make request and get response using httpx
url = f"http://{server}/v1/chat/completions"
async with httpx.AsyncClient(timeout=300) as client:
response = await client.post(url, json=request)
response.raise_for_status()
data = response.json()
choice = data["choices"][0]
return choice["message"]["content"]
+112
View File
@@ -0,0 +1,112 @@
import asyncio
import logging
from dataclasses import dataclass
from typing import Optional
# Import necessary components from olmocr
from olmocr.pipeline import (
MetricsKeeper,
PageResult,
WorkerTracker,
process_page,
vllm_server_host,
vllm_server_ready,
)
# Setup basic logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger("olmocr_runner")
# Basic configuration
@dataclass
class Args:
model: str = "allenai/olmOCR-2-7B-1025-FP8"
server: str = "http://localhost:30044/v1"
port: int = 30044
model_chat_template: str = "qwen2-vl"
max_model_len: int = 16384
guided_decoding: bool = False
gpu_memory_utilization: float = 0.8
target_longest_image_dim: int = 1288
target_anchor_text_len: int = -1
max_page_retries: int = 8
max_page_error_rate: float = 0.004
tensor_parallel_size: int = 1
data_parallel_size: int = 1
server_check_lock = asyncio.Lock()
async def run_olmocr_pipeline(pdf_path: str, page_num: int = 1, model: str = "allenai/olmOCR-2-7B-1025-FP8") -> Optional[str]:
"""
Process a single page of a PDF using the official olmocr pipeline's process_page function
Args:
pdf_path: Path to the PDF file
page_num: Page number to process (1-indexed)
Returns:
The extracted text from the page or None if processing failed
"""
# Ensure global variables are initialized
global metrics, tracker
if "metrics" not in globals() or metrics is None:
metrics = MetricsKeeper(window=60 * 5)
if "tracker" not in globals() or tracker is None:
tracker = WorkerTracker()
args = Args()
args.model = model
semaphore = asyncio.Semaphore(1)
worker_id = 0 # Using 0 as default worker ID
# Ensure server is running
async with server_check_lock:
_server_task = None
try:
await asyncio.wait_for(vllm_server_ready(args), timeout=5)
logger.info("Using existing vllm server")
except Exception:
logger.info("Starting new vllm server")
_server_task = asyncio.create_task(vllm_server_host(args.model, args, semaphore))
await vllm_server_ready(args)
# Sets the model name used in the pipeline code, it's a hack sadly
args.model = "olmocr"
try:
# Process the page using the pipeline's process_page function
# Note: process_page expects both original path and local path
# In our case, we're using the same path for both
page_result: PageResult = await process_page(args=args, worker_id=worker_id, pdf_orig_path=pdf_path, pdf_local_path=pdf_path, page_num=page_num)
# Return the natural text from the response
if page_result and page_result.response and not page_result.is_fallback:
return page_result.response.natural_text
return None
except Exception as e:
logger.error(f"Error processing page: {type(e).__name__} - {str(e)}")
return None
finally:
# We leave the server running for potential reuse
pass
async def main():
# Example usage
pdf_path = "your_pdf_path.pdf"
page_num = 1
result = await run_olmocr_pipeline(pdf_path, page_num)
if result:
print(f"Extracted text: {result[:200]}...") # Print first 200 chars
else:
print("Failed to extract text from the page")
if __name__ == "__main__":
asyncio.run(main())
+31
View File
@@ -0,0 +1,31 @@
from threading import Lock
from paddleocr import PPStructureV3
# Run's paddle paddle as in the docs here: https://huggingface.co/PaddlePaddle/PP-OCRv5_server_det
# text_detection_model_name="PP-OCRv5_server_det",
# and using the PP-StructureV3 pipeline to create markdown
paddle_pipeline = None
paddle_pipeline_lock = Lock()
def run_paddlepaddle(pdf_path: str, page_num: int = 1, **kwargs) -> str:
global paddle_pipeline
with paddle_pipeline_lock:
if paddle_pipeline is None:
paddle_pipeline = PPStructureV3(
text_detection_model_name="PP-OCRv5_server_det",
use_doc_orientation_classify=False, # Use use_doc_orientation_classify to enable/disable document orientation classification model
use_doc_unwarping=False, # Use use_doc_unwarping to enable/disable document unwarping module
use_textline_orientation=False, # Use use_textline_orientation to enable/disable textline orientation classification model
device="gpu:0", # Use device to specify GPU for model inference
)
output = paddle_pipeline.predict(pdf_path)
result = ""
for cur_page_0_indexed, res in enumerate(output):
if cur_page_0_indexed == page_num - 1:
result = res.markdown["markdown_texts"]
return result
+23
View File
@@ -0,0 +1,23 @@
from threading import Lock
from paddleocr import PaddleOCRVL
# Using docs from here: https://huggingface.co/PaddlePaddle/PaddleOCR-VL
paddle_pipeline = None
paddle_pipeline_lock = Lock()
def run_paddlevl(pdf_path: str, page_num: int = 1, **kwargs) -> str:
global paddle_pipeline
with paddle_pipeline_lock:
if paddle_pipeline is None:
paddle_pipeline = PaddleOCRVL()
output = paddle_pipeline.predict(pdf_path)
result = ""
for cur_page_0_indexed, res in enumerate(output):
if cur_page_0_indexed == page_num - 1:
result = res.markdown["markdown_texts"]
return result
+58
View File
@@ -0,0 +1,58 @@
import httpx
from olmocr.data.renderpdf import render_pdf_to_base64png
async def run_rolmocr(
pdf_path: str,
page_num: int = 1,
server: str = "localhost:30000",
model: str = "reducto/RolmOCR",
temperature: float = 0.2,
target_longest_image_dim: int = 1024,
) -> str:
"""
Returns:
str: The OCR result in markdown format.
"""
# Convert the first page of the PDF to a base64-encoded PNG image.
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num, target_longest_image_dim=target_longest_image_dim)
request = {
"model": model,
"messages": [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{image_base64}"},
},
{
"type": "text",
"text": "Return the plain text representation of this document as if you were reading it naturally.\n",
},
],
}
],
"temperature": temperature,
"max_tokens": 4096,
}
# Make request and get response using httpx
url = f"http://{server}/v1/chat/completions"
async with httpx.AsyncClient(timeout=300) as client:
response = await client.post(url, json=request)
response.raise_for_status()
data = response.json()
choice = data["choices"][0]
assert (
choice["finish_reason"] == "stop"
), "Response from server did not finish with finish_reason stop as expected, this is probably going to lead to bad data"
return choice["message"]["content"]
+100
View File
@@ -0,0 +1,100 @@
import json
from typing import Literal
import httpx
from olmocr.bench.prompts import (
build_basic_prompt,
build_openai_silver_data_prompt_no_document_anchoring,
)
from olmocr.data.renderpdf import (
get_png_dimensions_from_base64,
render_pdf_to_base64png,
)
from olmocr.prompts.anchor import get_anchor_text
from olmocr.prompts.prompts import (
PageResponse,
build_finetuning_prompt,
build_no_anchoring_v4_yaml_prompt,
build_openai_silver_data_prompt,
build_openai_silver_data_prompt_v3_simple,
)
async def run_server(
pdf_path: str,
page_num: int = 1,
endpoint: str = "http://localhost:8000/v1",
model: str = "allenai/olmOCR-7B-0225-preview",
temperature: float = 0.1,
target_longest_image_dim: int = 1024,
prompt_template: Literal["full", "full_no_document_anchoring", "fullv3simple", "finetune_v4_yaml", "basic", "finetune"] = "fullv3simple",
response_template: Literal["plain", "json"] = "plain",
) -> str:
"""
Convert page of a PDF file to markdown by calling a request
running against an openai compatible server.
You can use this for running against vllm, sglang, servers
as well as mixing and matching different model's.
It will only make one direct request, with no retries or error checking.
Returns:
str: The OCR result in markdown format.
"""
# Convert the first page of the PDF to a base64-encoded PNG image.
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num, target_longest_image_dim=target_longest_image_dim)
anchor_text = get_anchor_text(pdf_path, page_num, pdf_engine="pdfreport")
if prompt_template == "full":
prompt = build_openai_silver_data_prompt(anchor_text)
elif prompt_template == "full_no_document_anchoring":
prompt = build_openai_silver_data_prompt_no_document_anchoring(anchor_text)
elif prompt_template == "finetune":
prompt = build_finetuning_prompt(anchor_text)
elif prompt_template == "basic":
prompt = build_basic_prompt()
elif prompt_template == "finetune_v4_yaml":
prompt = build_no_anchoring_v4_yaml_prompt()
elif prompt_template == "fullv3simple":
width, height = get_png_dimensions_from_base64(image_base64)
prompt = build_openai_silver_data_prompt_v3_simple(width, height)
else:
raise ValueError("Unknown prompt template")
request = {
"model": model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
],
}
],
"temperature": temperature,
"max_tokens": 8000,
}
# Make request and get response using httpx
url = f"{endpoint.rstrip('/')}/chat/completions"
async with httpx.AsyncClient(timeout=300) as client:
response = await client.post(url, json=request)
response.raise_for_status()
data = response.json()
choice = data["choices"][0]
assert (
choice["finish_reason"] == "stop"
), "Response from server did not finish with finish_reason stop as expected, this is probably going to lead to bad data"
if response_template == "json":
page_data = json.loads(choice["message"]["content"])
page_response = PageResponse(**page_data)
return page_response.natural_text
elif response_template == "plain":
return choice["message"]["content"]
+129
View File
@@ -0,0 +1,129 @@
import base64
import json
from io import BytesIO
from typing import Literal
import torch
from PIL import Image
from transformers import (
AutoProcessor,
Qwen2_5_VLForConditionalGeneration,
)
from olmocr.data.renderpdf import render_pdf_to_base64png
from olmocr.prompts.anchor import get_anchor_text
from olmocr.prompts.prompts import (
PageResponse,
build_finetuning_prompt,
build_no_anchoring_yaml_prompt,
build_openai_silver_data_prompt,
)
from olmocr.train.front_matter import FrontMatterParser
_cached_model = None
_cached_processor = None
def run_transformers(
pdf_path: str,
page_num: int = 1,
model_name: str = "allenai/olmOCR-7B-0725-FP8",
temperature: float = 0.1,
target_longest_image_dim: int = 1024,
prompt_template: Literal["full", "finetune", "yaml"] = "yaml",
response_template: Literal["plain", "json", "yaml"] = "yaml",
) -> str:
"""
Convert page of a PDF file to markdown by calling a request
running against an openai compatible server.
You can use this for running against vllm, sglang, servers
as well as mixing and matching different model's.
It will only make one direct request, with no retries or error checking.
Returns:
str: The OCR result in markdown format.
"""
# Initialize the model
global _cached_model, _cached_processor
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
if _cached_model is None:
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
model_name, torch_dtype=torch.bfloat16, device_map="auto", attn_implementation="flash_attention_2"
).eval()
processor = AutoProcessor.from_pretrained(model_name)
model = model.to(device)
_cached_model = model
_cached_processor = processor
else:
model = _cached_model
processor = _cached_processor
# Convert the first page of the PDF to a base64-encoded PNG image.
image_base64 = render_pdf_to_base64png(pdf_path, page_num=page_num, target_longest_image_dim=target_longest_image_dim)
if prompt_template == "yaml":
prompt = build_no_anchoring_yaml_prompt()
else:
anchor_text = get_anchor_text(pdf_path, page_num, pdf_engine="pdfreport")
if prompt_template == "full":
prompt = build_openai_silver_data_prompt(anchor_text)
else:
prompt = build_finetuning_prompt(anchor_text)
messages = [
{
"role": "user",
"content": [
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_base64}"}},
{"type": "text", "text": prompt},
],
}
]
# Apply the chat template and processor
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
main_image = Image.open(BytesIO(base64.b64decode(image_base64)))
inputs = processor(
text=[text],
images=[main_image],
padding=True,
return_tensors="pt",
)
inputs = {key: value.to(device) for (key, value) in inputs.items()}
# Generate the output
MAX_NEW_TOKENS = 3000
with torch.no_grad():
output = model.generate(
**inputs,
temperature=temperature,
max_new_tokens=MAX_NEW_TOKENS,
num_return_sequences=1,
do_sample=True,
)
# Decode the output
prompt_length = inputs["input_ids"].shape[1]
new_tokens = output[:, prompt_length:]
text_output = processor.tokenizer.batch_decode(new_tokens, skip_special_tokens=True)[0]
assert new_tokens.shape[1] < MAX_NEW_TOKENS, "Output exceed max new tokens"
if response_template == "json":
page_data = json.loads(text_output)
page_response = PageResponse(**page_data)
return page_response.natural_text if page_response.natural_text else ""
elif response_template == "yaml":
# Parse YAML front matter and extract natural text
parser = FrontMatterParser(front_matter_class=PageResponse)
front_matter, text = parser._extract_front_matter_and_text(text_output)
page_response = parser._parse_front_matter(front_matter, text)
return page_response.natural_text if page_response.natural_text else ""
elif response_template == "plain":
return text_output
+1
View File
@@ -0,0 +1 @@
{"pdf": "blank_book_pg1.pdf", "page": 1, "id": "test1_blank", "type": "baseline", "checked": "verified", "max_length": 10}
+117
View File
@@ -0,0 +1,117 @@
{"pdf": "multi_column_miss.pdf", "page": 1, "id": "multi_column_miss_00", "type": "present", "text": "Corporate social responsibility and the tobacco industry: hope or hype?"}
{"pdf": "multi_column_miss.pdf", "page": 1, "id": "multi_column_miss_01", "type": "present", "text": "this leaves BAT to argue why it should not be held to be largely accountable for the annual deaths of some 754 600 smokers, and Philip Morris some 803 600 smokers."}
{"pdf": "multi_column_miss.pdf", "page": 1, "id": "multi_column_miss_02", "type": "present", "text": "The term \"corporate social responsibility\" is in vogue at the moment but as a concept it is vague and means different things to different people.", "max_diffs": 2}
{"pdf": "multi_column_miss.pdf", "page": 1, "id": "multi_column_miss_03", "type": "present", "text": "Over the past three decades increasing pressure from non-governmental"}
{"pdf": "multi_column_miss.pdf", "page": 1, "id": "multi_column_miss_04", "type": "absent", "text": "Downloaded from http://tobaccocontrol.bmj.com/"}
{"pdf": "multi_column_miss.pdf", "page": 1, "id": "multi_column_miss_10", "type": "order", "before": "Corporate social responsibility and the tobacco industry: hope or hype?", "after": "The unprecedented expansion of power and influence of TNCs over the past three decades has accelerated global trade and development, but also environmental damage and abuses of", "max_diffs": 2}
{"pdf": "multi_column_miss.pdf", "page": 1, "id": "multi_column_miss_11", "type": "order", "before": "It now looks like that with vigilance", "after": "this leaves BAT to argue why it should not be held to be largely accountable for the annual deaths", "max_diffs": 2}
{"pdf": "multi_column_miss.pdf", "page": 1, "id": "multi_column_miss_12", "type": "order", "before": "Corporate social responsibility (CSR) emerged from a realisation among transnational corporations", "after": " perspective on its own behaviour; and reflects on whether marketing tobacco is antithetical to social responsibility.", "max_diffs": 2}
{"pdf": "discoverworld_crazy_table4.pdf", "page": 1, "id": "discoverworld_crazy_table4_00", "type": "present", "text": "Table 4: Baseline model performance on each of the three scoring metrics"}
{"pdf": "discoverworld_crazy_table4.pdf", "page": 1, "id": "discoverworld_crazy_table4_01", "type": "present", "text": "Table 5: Baseline model performance on each of the three scoring metrics"}
{"pdf": "discoverworld_crazy_table4.pdf", "page": 1, "id": "discoverworld_crazy_table4_02", "type": "present", "text": "We use the GPT-4O model for all our agents due to its higher performance and lower cost compared to other models. For space we provide"}
{"pdf": "mattsnotes.pdf", "page": 1, "id": "mattsnotes_minediff_00", "type": "present", "checked": "verified", "text": "The-Stack-V2"}
{"pdf": "mattsnotes.pdf", "page": 1, "id": "mattsnotes_minediff_01", "type": "present", "checked": "verified", "text": "SE, whatever we've scraped"}
{"pdf": "mattsnotes.pdf", "page": 1, "id": "mattsnotes_minediff_02", "type": "present", "checked": "verified", "text": "HQ DCLM"}
{"pdf": "mattsnotes.pdf", "page": 2, "id": "mattsnotes_minediff_03", "type": "present", "checked": "verified", "text": "Order by repo"}
{"pdf": "mattsnotes.pdf", "page": 3, "id": "mattsnotes_minediff_04", "type": "present", "checked": "verified", "text": "ARCH + TRAINING"}
{"pdf": "buildingnotes.pdf", "page": 1, "id": "building_notes_00", "type": "present", "checked": "verified", "text": "Master Bath", "case_sensitive": false}
{"pdf": "buildingnotes.pdf", "page": 1, "id": "building_notes_01", "type": "present", "checked": "verified", "text": "Laundry", "case_sensitive": false}
{"pdf": "buildingnotes.pdf", "page": 1, "id": "building_notes_02", "type": "present", "checked": "verified", "text": "Guest Bath", "case_sensitive": false}
{"pdf": "lincoln_letter.pdf", "page": 1, "id": "lincoln_letter_minediff_00", "type": "present", "checked": "verified", "text": "January 10th 1864."}
{"pdf": "lincoln_letter.pdf", "page": 1, "id": "lincoln_letter_minediff_01", "type": "present", "checked": "verified", "text": "Major General Hitchcock, Commissioner of Exchanges, is authorized and directed to offer Brigadier General Trimble, now a prisoner of war in Fort McHenry, in exchange for Major White, who is held as a prisoner at Richmond."}
{"pdf": "lincoln_letter.pdf", "page": 1, "id": "lincoln_letter_minediff_03", "type": "present", "checked": "verified", "text": "He is also directed to send forward the offer of exchange by Henry M. Warfield, Esq. of Baltimore, under a flag of truce, and give him a pass to City Point."}
{"pdf": "openstax_caculus_pg_273.pdf", "page": 1, "id": "openstax_caculus_pg_273_minediff_02", "type": "present", "checked": "verified", "text": "Use the graph of the position function to determine the time intervals when the velocity is positive, negative, or zero."}
{"pdf": "openstax_caculus_pg_273.pdf", "page": 1, "id": "openstax_caculus_pg_273_minediff_03", "type": "present", "checked": "verified", "text": "Use the graph of the velocity function to determine the time intervals when the acceleration is positive, negative, or zero."}
{"pdf": "openstax_caculus_pg_273.pdf", "page": 1, "id": "openstax_caculus_pg_273_minediff_04", "type": "order", "before": "150.", "after": "157."}
{"pdf": "openstax_caculus_pg_273.pdf", "page": 1, "id": "openstax_caculus_pg_273_minediff_05", "type": "order", "before": "150.", "after": "158."}
{"pdf": "openstax_caculus_pg_273.pdf", "page": 1, "id": "openstax_caculus_pg_273_minediff_06", "type": "order", "before": "150.", "after": "159."}
{"pdf": "multi_column_miss.pdf", "page": 1, "id": "multi_column_miss_minediff_01", "type": "present", "checked": "verified", "text": "This report first provides the context and development of CSR; then, from internal company documents, examines how PM came to its own version."}
{"pdf": "multi_column_miss.pdf", "page": 1, "id": "multi_column_miss_minediff_02", "type": "present", "checked": "verified", "text": "This paper examines whether a tobacco company espousing CSR should be judged simply as a corporate entity along standards of business ethics, or as an irretrievably negative force in the realm of public health, thereby rendering CSR an oxymoron."}
{"pdf": "olmo2-pg4.pdf", "page": 1, "id": "olmo2-pg4_minediff_00", "type": "present", "checked": "verified", "text": "Table 1 Composition of the pretraining data for OLMo 2."}
{"pdf": "olmo2-pg4.pdf", "page": 1, "id": "olmo2-pg4_table00", "type": "table", "cell": "Type"}
{"pdf": "olmo2-pg4.pdf", "page": 1, "id": "olmo2-pg4_table01", "type": "table", "cell": "3.32T", "left": "3.71T"}
{"pdf": "olmo2-pg4.pdf", "page": 1, "id": "olmo2-pg4_table02", "type": "table", "cell": "3.32T", "right": "21.32T"}
{"pdf": "olmo2-pg4.pdf", "page": 1, "id": "olmo2-pg4_table03", "type": "table", "cell": "11.8B", "up": "12.2B"}
{"pdf": "olmo2-pg4.pdf", "page": 1, "id": "olmo2-pg4_table04", "type": "table", "cell": "11.8B", "down": "3.7B"}
{"pdf": "olmo2-pg4.pdf", "page": 1, "id": "olmo2-pg4_table05", "type": "table", "cell": "3.32T", "top_heading": "Words"}
{"pdf": "olmo2-pg4.pdf", "page": 1, "id": "olmo2-pg4_table06", "type": "table", "cell": "arXiv", "top_heading": "Source"}
{"pdf": "olmo2-pg4.pdf", "page": 1, "id": "olmo2-pg4_table07", "type": "table", "cell": "47.2B", "top_heading": "Bytes"}
{"pdf": "olmo2-pg4.pdf", "page": 1, "id": "olmo2-pg4_table08", "type": "table", "cell": "Math proofs code", "left_heading": "Algebraic Stack"}
{"pdf": "discoverworld_crazy_table4.pdf", "page": 1, "id": "olmo2-discoverworld_crazy_table4_t00", "type": "table", "cell": "Quadratic regression", "left": "Challenge"}
{"pdf": "discoverworld_crazy_table4.pdf", "page": 1, "id": "olmo2-discoverworld_crazy_table4_t01", "type": "table", "cell": "Instrument Use", "left": "Normal"}
{"pdf": "discoverworld_crazy_table4.pdf", "page": 1, "id": "olmo2-discoverworld_crazy_table4_t02", "type": "table", "cell": "0.87", "top_heading": "Procedure"}
{"pdf": "discoverworld_crazy_table4.pdf", "page": 1, "id": "olmo2-discoverworld_crazy_table4_t03", "type": "table", "cell": "0.87", "top_heading": "ReACT"}
{"pdf": "discoverworld_crazy_table4.pdf", "page": 1, "id": "olmo2-discoverworld_crazy_table4_t04", "type": "table", "cell": "Pick-and-place object", "left_heading": "27"}
{"pdf": "discoverworld_crazy_table4.pdf", "page": 1, "id": "olmo2-discoverworld_crazy_table4_t05", "type": "table", "cell": "0.66", "right": "0.44"}
{"pdf": "discoverworld_crazy_table4.pdf", "page": 1, "id": "olmo2-discoverworld_crazy_table4_t06", "type": "table", "cell": "Interact with a moving agent", "top_heading": "Unit Test Topic"}
{"pdf": "earnings.pdf", "page": 1, "id": "earnings_table00", "type": "table", "cell": "1,136", "top_heading": "Year Ended"}
{"pdf": "earnings.pdf", "page": 1, "id": "earnings_table01", "type": "table", "cell": "Year Ended"}
{"pdf": "earnings.pdf", "page": 1, "id": "earnings_table02", "type": "table", "cell": "680", "up": "1,892"}
{"pdf": "earnings.pdf", "page": 1, "id": "earnings_table03", "type": "table", "cell": "2,532", "left_heading": "Research and development"}
{"pdf": "earnings.pdf", "page": 1, "id": "earnings_table04", "type": "absent", "text": "62"}
{"pdf": "mathfuncs.pdf", "page": 1, "id": "mathfuncs_00", "type": "order", "before": "Euler's Identity", "after": "Pythagorean Theorem"}
{"pdf": "mathfuncs.pdf", "page": 1, "id": "mathfuncs_01", "type": "order", "before": "Pythagorean Theorem", "after": "The Fundamental Theorem of Calculus"}
{"pdf": "mathfuncs.pdf", "page": 1, "id": "mathfuncs_02", "type": "order", "before": "The Fundamental Theorem of Calculus", "after": "Maxwell's Equations"}
{"pdf": "mathfuncs.pdf", "page": 1, "id": "mathfuncs_03", "type": "math", "math": "e^{i \\pi}+1=0"}
{"pdf": "mathfuncs.pdf", "page": 1, "id": "mathfuncs_04", "type": "math", "math": "\\int_{a}^{b} f(x) d x=F(b)-F(a)"}
{"pdf": "mathfuncs.pdf", "page": 1, "id": "mathfuncs_05", "type": "math", "math": "a^{2}+b^{2}=c^{2}"}
{"pdf": "mathfuncs.pdf", "page": 1, "id": "mathfuncs_06", "type": "math", "math": "\\nabla \\times \\mathbf{E}=-\\frac{\\partial \\mathbf{B}}{\\partial t}"}
{"pdf": "mathfuncs_colswitch.pdf", "page": 1, "id": "mathfuncscol_00", "type": "order", "before": "Euler's Identity", "after": "Pythagorean Theorem"}
{"pdf": "mathfuncs_colswitch.pdf", "page": 1, "id": "mathfuncscol_01", "type": "order", "before": "Pythagorean Theorem", "after": "The Fundamental Theorem of Calculus"}
{"pdf": "mathfuncs_colswitch.pdf", "page": 1, "id": "mathfuncscol_02", "type": "order", "before": "The Fundamental Theorem of Calculus", "after": "Maxwell's Equations"}
{"pdf": "mathfuncs_colswitch.pdf", "page": 1, "id": "mathfuncscol_03", "type": "math", "math": "e^{i \\pi}+1=0"}
{"pdf": "mathfuncs_colswitch.pdf", "page": 1, "id": "mathfuncscol_04", "type": "math", "math": "\\int_{a}^{b} f(x) d x=F(b)-F(a)"}
{"pdf": "mathfuncs_colswitch.pdf", "page": 1, "id": "mathfuncscol_05", "type": "math", "math": "a^{2}+b^{2}=c^{2}"}
{"pdf": "mathfuncs_colswitch.pdf", "page": 1, "id": "mathfuncscol_06", "type": "math", "math": "\\nabla \\times \\mathbf{E}=-\\frac{\\partial \\mathbf{B}}{\\partial t}"}
{"pdf": "math_2503_04086.pdf", "page": 1, "id": "math_2503_04086_00", "type": "math", "math": "\\lambda_{g}=\\sum_{s \\in S} \\zeta_{n}^{\\psi(g s)}=\\sum_{i=1}^{k}\\left[\\sum_{s, R s=\\mathcal{I}_{i}} \\zeta_{n}^{\\psi(g s)}\\right]"}
{"pdf": "math_2503_04086.pdf", "page": 1, "id": "math_2503_04086_01", "type": "math", "math": "\\lambda_{g}=\\lambda_{g^{\\prime}}"}
{"pdf": "math_2503_04086.pdf", "page": 1, "id": "math_2503_04086_02", "type": "math", "math": "u \\in\\left(R / \\operatorname{Ann}_{R}\\left(x_{i}\\right)\\right)^{\\times}"}
{"pdf": "math_2503_04086.pdf", "page": 1, "id": "math_2503_04086_03", "type": "math", "math": "\\lambda_{g}=\\sum_{i=1}^{k} c\\left(g, R / \\operatorname{Ann}_{R}\\left(x_{i}\\right)\\right)"}
{"pdf": "math_2503_04086.pdf", "page": 1, "id": "math_2503_04086_04", "type": "present", "text": "We also thank Ján Mináč for his constant encouragement and support."}
{"pdf": "math_2503_04086.pdf", "page": 1, "id": "math_2503_04086_05", "type": "present", "text": "Allgemeine theorie der Gaußschen Summen in endlichen kommutativen Ringe"}
{"pdf": "test-graphical-text.pdf", "page": 1, "id": "test_graphical_text_00", "type": "present", "text": "THE VISION"}
{"pdf": "test-graphical-text.pdf", "page": 1, "id": "test_graphical_text_01", "type": "present", "text": "by FutureSkill"}
{"pdf": "test-graphical-text.pdf", "page": 1, "id": "test_graphical_text_02", "type": "present", "text": "THE POWER OF STORYTELLING"}
{"pdf": "small_page_size.pdf", "page": 1, "id": "small_page_size_00", "type": "present", "text": "Since the use of bones has, however, become general, the turnip crop has been, in many instances, ten-fold, and in few less than four or five-fold its former bulk"}
{"pdf": "small_page_size.pdf", "page": 1, "id": "small_page_size_01", "type": "order", "before": "Earthy and saline matter", "after": "Cartilage and jelly"}
{"pdf": "small_page_size.pdf", "page": 1, "id": "small_page_size_02", "type": "present", "text": "The cartilage, indeed, when the bones have been buried in a dry situation, is very indestructible"}
{"pdf": "small_page_size.pdf", "page": 1, "id": "small_page_size_03", "type": "absent", "text": "BRITISH HUSBANDRY"}
{"pdf": "small_page_size.pdf", "page": 1, "id": "small_page_size_04", "type": "absent", "text": "400"}
{"pdf": "small_page_size.pdf", "page": 1, "id": "small_page_size_05", "type": "absent", "text": "XIX"}
{"pdf": "headers_footers/ff0f0b22c55d8b90dd77d153f48e144fc9db_pg2.pdf", "page": 1, "id": "ff0f0b22c55d8b90dd77d153f48e144fc9db_02a", "type": "absent", "text": "PLOS Neglected Tropical Diseases | www.plosntds.org"}
{"pdf": "headers_footers/ff0f0b22c55d8b90dd77d153f48e144fc9db_pg2.pdf", "page": 1, "id": "ff0f0b22c55d8b90dd77d153f48e144fc9db_02b", "type": "absent", "text": "March 2014 | Volume 8 | Issue 3 | e2748"}
{"pdf": "headers_footers/ff0f0b22c55d8b90dd77d153f48e144fc9db_pg2.pdf", "page": 1, "id": "ff0f0b22c55d8b90dd77d153f48e144fc9db_02c", "type": "absent", "text": "1", "last_n": 20}
{"pdf": "headers_footers/ff0f0b22c55d8b90dd77d153f48e144fc9db_pg2.pdf", "page": 1, "id": "ff0f0b22c55d8b90dd77d153f48e144fc9db_02d", "type": "absent", "text": "OPEN ACCESS Freely available online"}
{"pdf": "headers_footers/ff0f0b22c55d8b90dd77d153f48e144fc9db_pg2.pdf", "page": 1, "id": "ff0f0b22c55d8b90dd77d153f48e144fc9db_02e", "type": "absent", "text": "PLOS NEGLECTED TROPICAL DISEASES"}
{"pdf": "headers_footers/ff1fc6a205ad039139ce566851b6b260c929_pg1.pdf", "page": 1, "id": "ff1fc6a205ad039139ce566851b6b260c929_01a", "type": "absent", "text": "TELEDYNE ENERGY SYSTEMS, INC. A Teledyne Technologies Company"}
{"pdf": "headers_footers/ff1fc6a205ad039139ce566851b6b260c929_pg1.pdf", "page": 1, "id": "ff1fc6a205ad039139ce566851b6b260c929_01b", "type": "absent", "text": "AEROJET ROCKETDYNE"}
{"pdf": "headers_footers/ff1fc6a205ad039139ce566851b6b260c929_pg1.pdf", "page": 1, "id": "ff1fc6a205ad039139ce566851b6b260c929_01c", "type": "absent", "text": "1", "last_n": 20}
{"pdf": "headers_footers/ff3d6e051903fe5ca9bc172ece14964c5632_pg1.pdf", "page": 1, "id": "ff3d6e051903fe5ca9bc172ece14964c5632_01a", "type": "absent", "text": "farbod4ever@gmail.com :\u0646\u0648\u064a\u0633\u0646\u062f\u0647 \u0631\u0627\u0628\u0637", "max_diffs": 4}
{"pdf": "headers_footers/ff3d6e051903fe5ca9bc172ece14964c5632_pg1.pdf", "page": 1, "id": "ff3d6e051903fe5ca9bc172ece14964c5632_01b", "type": "absent", "text": "Downloaded from jipm.irandoc.ac.ir at 6:51 IRST on Monday November 11th 2019"}
{"pdf": "headers_footers/ff4f7dad78081cff727d19ab51c181d4a661_pg1.pdf", "page": 1, "id": "ff4f7dad78081cff727d19ab51c181d4a661_01a", "type": "absent", "text": "Download date: 28 Dec 2018"}
{"pdf": "headers_footers/ff518b1240a66978f22035528ccb029450b5_pg2.pdf", "page": 1, "id": "ff518b1240a66978f22035528ccb029450b5_02a", "type": "absent", "text": "Woodworth et al.: Brief Notices"}
{"pdf": "headers_footers/ff518b1240a66978f22035528ccb029450b5_pg2.pdf", "page": 1, "id": "ff518b1240a66978f22035528ccb029450b5_02b", "type": "absent", "text": "Published by BYU ScholarsArchive, 1997"}
{"pdf": "headers_footers/ff518b1240a66978f22035528ccb029450b5_pg2.pdf", "page": 1, "id": "ff518b1240a66978f22035528ccb029450b5_02c", "type": "absent", "text": "199", "last_n": 20}
{"pdf": "headers_footers/ffaac214730d2b8c2ec842e3618ccb9c4259_pg1.pdf", "page": 1, "id": "ffaac214730d2b8c2ec842e3618ccb9c4259_01a", "type": "absent", "text": "TILBURG UNIVERSITY"}
{"pdf": "headers_footers/ffaac214730d2b8c2ec842e3618ccb9c4259_pg1.pdf", "page": 1, "id": "ffaac214730d2b8c2ec842e3618ccb9c4259_01b", "type": "absent", "text": "Download date: 15. Sep. 2023"}
{"pdf": "headers_footers/fff590bed29a2854ac1f874dad5752ede1aa_pg1.pdf", "page": 1, "id": "fff590bed29a2854ac1f874dad5752ede1aa_01a", "type": "absent", "text": "Revision: 2.4"}
{"pdf": "headers_footers/fff590bed29a2854ac1f874dad5752ede1aa_pg1.pdf", "page": 1, "id": "fff590bed29a2854ac1f874dad5752ede1aa_01b", "type": "absent", "text": "P/N 119-036"}
{"pdf": "headers_footers/fff590bed29a2854ac1f874dad5752ede1aa_pg1.pdf", "page": 1, "id": "fff590bed29a2854ac1f874dad5752ede1aa_01c", "type": "absent", "text": "10 June 2019"}
@@ -0,0 +1,11 @@
Master - 7 1/4 - 36"
Master Bath - 7 1/4 - 30"
Laundry - 4 3/4 - 36"
Bath - 7 1/4 - 24"
MUD - 7 - 36"
UTIL - 8 1/4 - 36"
DOWN BATH - 7 1/4 - 32"
BUT KIT - 6 3/4 - 30
PANTRY - 4 3/4 - 24
6 WEST - 32 9/8 - 32
6 WEST BATH 5" - 24"
@@ -0,0 +1,51 @@
Table 4: Baseline model performance on each of the three scoring metrics (task completion, task process, explanatory knowledge discovery) across all 24 DISCOVERY WORLD tasks. Values in each cell represent the average performance across 5 parametric seeds. Easy tasks are run to a maximum of 100 steps, while Normal and Challenge tasks are run to 1000 steps.
| # | Topic | Task | ReACT | Plan+Execute | Hypothesizer |
|---|---|---|---|---|---|
| 1 | Proteomics | Clustering | 0.87 | 0.20 | 0.20 | 0.80 | 0.00 | 0.00 | 0.90 | 0.40 | 1.00 |
| 2 | Chemistry | Exploring Combinations and Hill Climbing | 0.88 | 0.40 | 0.40 | 0.68 | 0.20 | 0.00 | 0.93 | 0.40 | 0.40 |
| 3 | Archaeology | Correlations | 0.88 | 0.40 | 0.60 | 0.55 | 0.20 | 0.00 | 0.93 | 0.40 | 0.60 |
| 4 | Easy | Single substances | 0.87 | 1.00 | 1.00 | 0.70 | 0.60 | 0.40 | 0.90 | 0.00 | 0.40 |
| 5 | Normal | Mix of 3 substances | 0.82 | 0.00 | 0.00 | 0.87 | 0.40 | 0.00 | 0.93 | 0.60 | 0.40 |
| 6 | Challenge | Mix of 4 substances | 0.90 | 0.40 | 0.00 | 0.90 | 0.40 | 0.00 | 0.97 | 0.00 | 0.00 |
| 7 | Easy | Simple instrument | 0.27 | 0.60 | 0.00 | 0.33 | 0.20 | 0.00 | 0.60 | 0.20 | 0.50 |
| 8 | Normal | Instrument Use | 0.72 | 0.40 | 0.30 | 0.74 | 0.00 | 0.00 | 0.64 | 0.40 | 0.40 |
| 9 | Challenge | Correlation | 0.46 | 0.20 | 0.00 | 0.46 | 0.00 | 0.05 | 0.55 | 0.20 | 0.05 |
| 10 | Reactor Lab | Regression | 0.42 | 0.00 | 0.40 | 0.44 | 0.00 | 0.10 | 0.38 | 0.00 | 0.20 |
| 11 | Normal | Linear regression | 0.44 | 0.00 | 0.20 | 0.49 | 0.00 | 0.00 | 0.51 | 0.00 | 0.00 |
| 12 | Challenge | Quadratic regression | 0.43 | 0.00 | 0.20 | 0.39 | 0.00 | 0.00 | 0.39 | 0.00 | 0.00 |
| 13 | Easy | Simplified rules | 0.80 | 0.20 | 0.20 | 0.70 | 0.20 | 0.20 | 0.60 | 0.00 | 0.00 |
| 14 | Normal | Presence rules | 0.91 | 0.60 | 0.00 | 0.84 | 0.40 | 0.00 | 0.56 | 0.00 | 0.00 |
| 15 | Challenge | Logical Rules | 0.89 | 0.40 | 0.00 | 0.73 | 0.40 | 0.00 | 0.62 | 0.00 | 0.00 |
| 16 | Easy | Open-ended discovery | 0.78 | 0.60 | 0.00 | 0.68 | 0.40 | 0.10 | 0.80 | 1.00 | 0.60 |
| 17 | Normal | Multiple instruments | 0.58 | 0.00 | 0.13 | 0.45 | 0.00 | 0.13 | 0.16 | 0.00 | 0.33 |
| 18 | Challenge | Novel instruments | 0.55 | 0.00 | 0.00 | 0.26 | 0.00 | 0.00 | 0.20 | 0.00 | 0.00 |
| 19 | Easy | Look-up variables | 0.33 | 0.00 | 0.00 | 0.53 | 0.00 | 0.07 | 0.13 | 0.40 | 0.00 |
| 20 | Normal | Measure 2 variables | 0.51 | 0.00 | 0.05 | 0.34 | 0.00 | 0.00 | 0.11 | 0.00 | 0.00 |
| 21 | Challenge | Measure 5 variables | 0.43 | 0.00 | 0.00 | 0.15 | 0.00 | 0.00 | 0.22 | 0.00 | 0.03 |
| 22 | Easy | Rosetta-stone style linguistic discovery of alien language | 0.40 | 0.40 | 0.20 | 0.30 | 0.00 | 0.00 | 0.20 | 0.20 | 0.00 |
| 23 | Normal | Noun and verb | 0.20 | 0.00 | 0.00 | 0.68 | 0.40 | 0.00 | 0.84 | 0.40 | 0.00 |
| 24 | Challenge | Noun, adj., and verb | 0.49 | 0.00 | 0.00 | 0.55 | 0.20 | 0.05 | 0.15 | 0.00 | 0.00 |
| Average (Easy) | 0.59 | 0.38 | 0.25 | 0.56 | 0.18 | 0.11 | 0.56 | 0.28 | 0.34 |
| Average (Normal) | 0.63 | 0.18 | 0.14 | 0.64 | 0.18 | 0.02 | 0.58 | 0.23 | 0.19 |
| Average (Challenge) | 0.63 | 0.18 | 0.10 | 0.50 | 0.15 | 0.01 | 0.49 | 0.08 | 0.08 |
Table 5: Baseline model performance on each of the three scoring metrics (task completion, task process, explanatory knowledge discovery) across all 10 unit test tasks. Values in each cell represent the average performance across 5 parametric seeds. Unit tests tasks are run to a maximum of 100 steps.
| # | Unit Test Topic | ReACT | Plan+Execute | Hypothesizer |
|---|---|---|---|---|
| 25 | Multi-turn dialog with an agent | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| 26 | Measure an object with an instrument | 0.87 | 0.60 | 0.73 | 0.40 | 1.00 | 1.00 |
| 27 | Pick-and-place object | 0.90 | 0.80 | 0.80 | 0.60 | 1.00 | 1.00 |
| 28 | Pick-and-give object | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| 29 | Read DiscoveryFeed posts | 1.00 | 1.00 | 0.90 | 0.80 | 1.00 | 1.00 |
| 30 | Move through doors | 0.58 | 0.20 | 0.25 | 0.00 | 0.30 | 0.00 |
| 31 | Using keys with doors | 0.69 | 0.20 | 0.54 | 0.00 | 0.69 | 0.00 |
| 32 | Navigate to a specific room in a house | 0.20 | 0.20 | 0.20 | 0.00 | 0.20 | 0.20 |
| 33 | Search an environment for an object | 0.80 | 0.80 | 0.60 | 0.60 | 1.00 | 1.00 |
| 34 | Interact with a moving agent | 0.60 | 0.20 | 0.53 | 0.00 | 0.53 | 0.20 |
| Average (Unit Tests) | 0.76 | 0.60 | 0.66 | 0.44 | 0.77 | 0.64 |
4.2 Baseline Agent Models
The baseline agents are described below, with model performance on Discovery tasks shown in Table 4, and performance on Unit Tests shown in Table 5. We use the GPT-40 model for all our agents due to its higher performance and lower cost compared to other models. For space we provide
@@ -0,0 +1,33 @@
Recently Issued Accounting Pronouncements
Recently Adopted Accounting Pronouncement
In November 2023, the Financial Accounting Standards Board, or FASB, issued a new accounting standard requiring disclosures of significant expenses in operating segments. We adopted this standard in our fiscal year 2025 annual report. Refer to Note 16 of the Notes to the Consolidated Financial Statements in Part IV, Item 15 of this Annual Report on Form 10-K for further information.
Recent Accounting Pronouncements Not Yet Adopted
In December 2023, the FASB issued a new accounting standard which includes new and updated income tax disclosures, including disaggregation of information in the rate reconciliation and income taxes paid. We expect to adopt this standard in our fiscal year 2026 annual report. We do not expect the adoption of this standard to have a material impact on our Consolidated Financial Statements other than additional disclosures.
In November 2024, the FASB issued a new accounting standard requiring disclosures of certain additional expense information on an annual and interim basis, including, among other items, the amounts of purchases of inventory, employee compensation, depreciation and intangible asset amortization included within each income statement expense caption, as applicable. We expect to adopt this standard in our fiscal year 2028 annual report. We do not expect the adoption of this standard to have a material impact on our Consolidated Financial Statements other than additional disclosures.
Note 2 - Business Combination
Termination of the Arm Share Purchase Agreement
In February 2022, NVIDIA and SoftBank Group Corp, or SoftBank, announced the termination of the Share Purchase Agreement whereby NVIDIA would have acquired Arm from SoftBank. The parties agreed to terminate it due to significant regulatory challenges preventing the completion of the transaction. We recorded an acquisition termination cost of $1.4 billion in fiscal year 2023 reflecting the write-off of the prepayment provided at signing.
Note 3 - Stock-Based Compensation
Stock-based compensation expense is associated with RSUs, PSUs, market-based PSUs, and our ESPP.
Consolidated Statements of Income include stock-based compensation expense, net of amounts capitalized into inventory and subsequently recognized to cost of revenue, as follows:
| Year Ended | Jan 29, 2023 | Jan 28, 2024 | Jan 29, 2023 |
|------------|--------------|--------------|--------------|
| (In millions) | $138 | $141 | $138 |
| Cost of revenue | $178 | $141 | $138 |
| Research and development | 3,423 | 2,532 | 1,892 |
| Sales, general and administrative | 1,136 | 876 | 680 |
| Total | $4,737 | $3,549 | $2,710 |
Stock-based compensation capitalized in inventories was not significant during fiscal years 2025, 2024, and 2023.
@@ -0,0 +1,23 @@
Lassa Fever in Post-Conflict Sierra Leone
Jeffrey G. Shaffer1, Donald S. Grant2, John S. Schieffelin3, Matt L. Boisen4,5, Augustine Goba2,9, Jessica N. Hartnett4, Danielle C. Levy4, Rachael E. Yenni4, Lina M. Moses4,6, Mohammed Fullah2, Mambo Momoh2, Mbalu Fonnie2, Richard Fonnie2, Lansana Kanneh2, Veronica J. Koroma2, Kande Kargbo2, Darin Ottomassathien9, Ivana J. Muncy5, Abigail B. Jones5, Megan M. Illic7, Peter C. Kulakosky8, Allyson M. Haislip4, Christopher M. Bishop4, Deborah H. Elliot3, Bethany L. Brown5, Hu Zhu9, Kathryn M. Hastie10, Kristian G. Andersen11,12, Stephen K. Gire11,12, Shervin Tabrizi11,12, Ridhi Tariyal12, Mathew Stremlau11,12, Alex Matschiner7, Darryl B. Sampey7, Jennifer S. Spence6, Robert W. Cross4,13, Joan B. Geisbert13, Onikepe A. Folarin14, Christian T. Happi14, Kelly R. Pitts5, F. Jon Geske5, Thomas W. Geisbert13, Erica Ollmann Saphire9,15, James E. Robinson3, Russell B. Wilson8, Pardis C. Sabeti10,11,16, Lee A. Henderson8, S. Humarr Khan25, Daniel G. Bausch61, Luis M. Branco8,17, Robert F. Garry4,12,17*, the Viral Hemorrhagic Fever Consortium†
1 Department of Biostatistics and Bioinformatics, Tulane School of Public Health and Tropical Medicine, New Orleans, Louisiana, United States of America, 2 Lassa Fever Program, Kenema Government Hospital, Kenema, Sierra Leone, 3 Sections of Infectious Disease, Departments of Pediatrics and Internal Medicine, School of Medicine, Tulane University, New Orleans, Louisiana, United States of America, 4 Department of Microbiology and Immunology, Tulane University, New Orleans, Louisiana, United States of America, 5 Corgenix, Inc., Broomfield, Colorado, United States of America, 6 Department of Tropical Medicine, Tulane School of Public Health and Tropical Medicine, New Orleans, Louisiana, United States of America, 7 Biofactura Inc., Rockville, Maryland, United States of America, 8 Autoimmune Technologies, LLC, New Orleans, Louisiana, United States of America, 9 Vybion Inc., Ithaca, New York, United States of America, 10 Department of Immunology and Microbial Science, The Scripps Research Institute, La Jolla, California, United States of America, 11 FAS Center for Systems Biology, Department of Organismic and Evolutionary Biology, Harvard University, Cambridge, Massachusetts, United States of America, 12 Broad Institute, Cambridge, Massachusetts, United States of America, 13 Department of Microbiology and Immunology, University of Texas Medical Branch at Galveston, Galveston, Texas, United States of America, 14 Department of Biological Sciences, College of Natural Sciences, Redeemers University, Redemption City, Ogun State, Nigeria, 15 The Skaggs Institute for Chemical Biology, The Scripps Research Institute, La Jolla, California, United States of America, 16 Department of Immunology and Infectious Disease, Harvard School of Public Health, Boston, Massachusetts, United States of America, 17 Zalgen Labs, LLC, Germantown, Maryland, United States of America
Abstract
Background: Lassa fever (LF), an often-fatal hemorrhagic disease caused by Lassa virus (LASV), is a major public health threat in West Africa. When the violent civil conflict in Sierra Leone (1991 to 2002) ended, an international consortium assisted in restoration of the LF program at Kenema Government Hospital (KGH) in an area with the worlds highest incidence of the disease.
Methodology/Principal Findings: Clinical and laboratory records of patients presenting to the KGH Lassa Ward in the post-conflict period were organized electronically. Recombinant antigen-based LF immunoassays were used to assess LASV antigenemia and LASV-specific antibodies in patients who met criteria for suspected LF. KGH has been reestablished as a center for LF treatment and research, with over 500 suspected cases now presenting yearly. Higher case fatality rates (CFRs) in LF patients were observed compared to studies conducted prior to the civil conflict. Different criteria for defining LF stages and differences in sensitivity of assays likely account for these differences. The highest incidence of LF in Sierra Leone was observed during the dry season. LF cases were observed in ten of Sierra Leones thirteen districts, with numerous cases from outside the traditional endemic zone. Deaths in patients presenting with LASV antigenemia were skewed towards individuals less than 29 years of age. Women self-reporting as pregnant were significantly overrepresented among LASV antigenemic patients. The CFR of ribavirin-treated patients presenting early in acute infection was lower than in untreated subjects.
Conclusions/Significance: Lassa fever remains a major public health threat in Sierra Leone. Outreach activities should expand because LF may be more widespread in Sierra Leone than previously recognized. Enhanced case finding to ensure rapid diagnosis and treatment is imperative to reduce mortality. Even with ribavirin treatment, there was a high rate of fatalities underscoring the need to develop more effective and/or supplemental treatments for LF.
Citation: Shaffer JG, Grant DS, Schieffelin JS, Boisen ML, Goba A, et al. (2014) Lassa Fever in Post-Conflict Sierra Leone. PLoS Negl Trop Dis 8(3): e2748. doi:10.1371/journal.pntd.0002748
Editor: Brian Bird, Centers for Disease Control and Prevention, United States of America
Received August 10, 2013; Accepted February 3, 2014; Published March 20, 2014
Copyright: © 2014 Shaffer et al. This is an open-access article distributed under the terms of the Creative Commons Attribution License, which permits unrestricted use, distribution, and reproduction in any medium, provided the original author and source are credited.
Funding: This work was supported by National Institute of Allergy and Infectious Diseases grants/contracts AI067188, A082119, AI2008031, AI2009061, AI104216, AI104621, P20GM103501, HHSN272200900018C, HHSN272200900049C, HHSN272201000022C, and an Investigators in the Pathogenesis of Infectious Disease award from the Burroughs Wellcome Fund. The funders had no role in study design, data collection and analysis, decision to publish, or preparation of the manuscript.
@@ -0,0 +1,10 @@
RTG Degradation Primer and Application to MMRTG
Nuclear and Emerging Technology for Space (NETS) 2015
February 23-26, 2015
Abstract 5107
Presenting Author: Tom Hammel, Teledyne Energy Systems
Co-Authors: Russell Bennett, Teledyne Energy Systems
Robert Sievers, Teledyne Energy Systems
Bill Otting, Aerojet Rocketdyne
@@ -0,0 +1,9 @@
بررسی دیدگاه و نظرات کتابداران و اعضای هیئت علمی دانشگاه شیراز در بره گیری از فناوری شبکه‌های پی سیم در کتابخانه‌های دانشگاهی
چکیده: نظر به اهمیت و کاربرد گسترده شبکه‌های پی سیم در محیط‌های دانشگاهی در کشورهای پیشرفته و استفاده از آن در خدمات کتابخانه‌ای، بهره‌گیری از این فناوری در کتابخانه‌های دانشگاهی کشور احساس می‌شود. این پژوهش با استفاده از روش پیام‌برداری، با هدف معرفی شبکه‌های پی سیم، به بررسی دیدگاه و نظرات کتابداران و اعضای هیئت علمی دانشگاه شیراز، در یک کارگیری از این شبکه‌ها در کتابخانه‌های دانشگاهی پرداخت. باقی‌مانده‌های تحقیق نشان داد که کتابداران تا مایل زیادی به استفاده از شبکه‌های پی سیم در امر خدمات کتابخانه‌های تظیم می‌باشند و قفسه‌خوانی و دسترسی به فهرست عمومی پوسته دارند. و گسترش گی پوشش و سیاست‌های پیشرفته و سیاست‌های پیشرفته در کتابخانه‌ها را خواستار شده‌اند. در حالی که اعضای هیئت علمی، ضرورت استفاده بیشتر از این شبکه‌ها در کل محیط دانشگاه و دسترسی به منابع کتابخانه‌ای از خارج از محیط کتابخانه را خواستار شده‌اند. در کل، با توجه به نتایج حاصل از این تحقیق می‌توان گفت که هر چند استفاده از شبکه‌های پی سیم در کتابخانه‌ها پیش‌بینی‌های زیادی و نوآوری‌های می‌باشد و هنوز در کشور ما چندان مورد توجه قرار نگرفته و ناشناخته مانده است، اما تماشای کتابداران، پژوهشگران و استادان به استفاده و کاربرد آن در محیط‌های دانشگاهی زیاد است.
کلیدواژه‌ها: شبکه‌های پی سیم؛ کتابخانه‌های دانشگاهی؛ اعضای هیئت علمی؛ کتابداران؛ رایانه‌های قابل حمل؛ رایانه‌های دستی؛ منابع و خدمات کتابخانه‌ای؛ دانشگاه شیراز
farbod4ever@gmail.com
نویسنده رابطه:
@@ -0,0 +1,11 @@
Molecular markers of breast cancer metastasis
Weigelt, B.
Citation for published version (APA):
Weigelt, B. (2005). Molecular markers of breast cancer metastasis
General rights
It is not permitted to download or to forward/distribute the text or part of it without the consent of the author(s) and/or copyright holder(s), other than for strictly personal, individual use, unless the work is under an open content license (like Creative Commons).
Disclaimer/Complaints regulations
If you believe that digital publication of certain material infringes any of your rights or (privacy) interests, please let the Library know, stating your reasons. In case of a legitimate complaint, the Library will make the material inaccessible and/or remove it from the website. Please Ask the Library: http://uba.uva.nl/en/contact, or a letter to: Library of the University of Amsterdam, Secretariat, Singel 425, 1012 WP Amsterdam, The Netherlands. You will be contacted as soon as possible.

Some files were not shown because too many files have changed in this diff Show More