Pagen generates synthetic realistic-looking and diverse document images with word-level polygon annotations for training and evaluating text-detection (and OCR) models.
It produces train and val splits in a single command, with realistic augmentation (scan textures, photo perspective warp, photometric degradation) fused into generation: one final image per document, no intermediate copies.
The output follows the doctr detection-dataset layout:
{
"1.png": {
"img_dimensions": [width, height],
"img_hash": "<sha256 of the png>",
"polygons": [[[x1, y1], [x2, y1], [x2, y2], [x1, y2]], ...],
"labels": ["الكلمة", "الأولى", ...]
}
}Each polygon is a 4-point quadrilateral (TL, TR, BR, BL) wrapping a single word. Rendering uses WeasyPrint (Markdown → HTML → PDF); word labels come from WeasyPrint's layout tree so Arabic shaping and Eastern-Arabic digits are always correct. PyMuPDF rasterizes the PDF and supplies tight per-glyph geometry so polygons fit the visible ink rather than the loose em-box.
WeasyPrint needs Pango, Cairo, and friends. PyMuPDF bundles its own MuPDF; no extra system packages are needed for rasterization.
sudo apt-get install build-essential python3-dev python3-cffi \
libcairo2 libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0 \
libffi-dev shared-mime-infopython -m venv .venv
source .venv/bin/activate
pip install -e .
# or without editable install:
pip install -r requirements.txtOptional extras:
pip install python-dotenv # auto-load .env for API keys
pip install arabic-reshaper matplotlib # pagen visualize --showDrop Arabic .ttf files into resources/fonts/ (or pass --fonts-dir). A good source is Google Fonts. Color fonts (COLR/SVG) are skipped automatically.
Add word files to resources/corpora/ (one or more Arabic words per line; space-separated is fine), or point --corpus at a file or directory. Without any corpus, a tiny built-in fallback is used.
Augmentation draws on two folders of your own photos. Unsplash is a good free source for both.
resources/images/scene/(--scene-dir): photos of a blank sheet of paper lying in a real scene (on a desk, table, floor, with surrounding clutter). The rendered document is perspective-warped onto the paper region and composited into the photo, so the model sees documents at realistic angles against busy backgrounds. The paper's four corners are detected automatically and cached inpaper_corners.json(editable viapagen corners --edit), so pick photos where the sheet is clearly visible against its surroundings. Search Unsplash for blank paper, paper on desk, empty document.resources/images/textures/(--textures-dir): flat paper / scan textures (plain paper grain, aged or stained paper, photocopier noise) used as full-frame overlays. The rendered document is multiply-blended onto the texture to mimic a scanned or photocopied page, with no perspective, just surface character. Search Unsplash for paper texture, old paper, parchment.
The --clean-prob / --textures-prob / --scene-prob flags control how often each path (clean, texture overlay, scene warp) is chosen. If a folder is empty, its path is simply skipped.
# Interactive wizard: prompts for counts, output dir, mode
pagen
# Or specify everything:
pagen dataset --train 500 --val 100 -o data/The result loads directly with doctr:
from doctr.datasets import DetectionDataset
ds = DetectionDataset(img_folder="data/train/images", label_path="data/train/labels.json")pagen dataset --train N --val N [options]| Flag | Default | Description |
|---|---|---|
--train N |
0 |
Number of training documents (omit to skip the split; at least one of train/val must be > 0) |
--val N |
0 |
Number of validation documents (omit to skip the split) |
-o DIR |
output |
Root output directory; splits go in DIR/train and DIR/val |
--pdf-only |
off | Skip augmentation; produce clean rendered images |
--keep-txt |
off | Save plain-text word labels per page (.txt) |
--keep-pdf |
off | Save the intermediate PDF per document |
--dpi |
150 | Raster resolution |
--workers |
all CPUs | Parallel worker processes |
--seed |
42 | RNG seed |
--templates-dir |
templates |
Directory of .md templates |
--corpus |
auto | Corpus file or directory |
--fonts-dir |
fonts |
Directory of .ttf fonts |
Augmentation flags (ignored with --pdf-only):
| Flag | Default | Description |
|---|---|---|
--clean-prob |
0.10 | Fraction of images kept clean |
--textures-prob |
0.45 | Fraction composited onto a scan texture |
--scene-prob |
0.45 | Fraction perspective-warped onto a background photo |
--textures-dir |
resources/images/textures |
Scan texture images |
--scene-dir |
resources/images/scene |
Background photo images |
LLM content fill — on by default, falling back to random corpus words:
| Flag | Default | Description |
|---|---|---|
--llm |
— | Require the LLM — fail if the backend is unreachable |
--no-llm |
— | Use random corpus fill (skip the LLM entirely) |
--llm-base-url |
http://localhost:11434/v1 |
OpenAI-compatible API endpoint |
--llm-model |
gemma3:4b |
Model name |
--llm-temperature |
0.7 |
Sampling temperature for content fill |
--api-key-env |
OPENAI_API_KEY |
Env var holding the API key (never a CLI flag) |
--fill-variants |
10 |
LLM fills pre-generated per template, then sampled by workers |
By default the tool probes the LLM backend and uses it if it responds. If the
backend is unreachable it says so and asks whether to fall back to random corpus
fill (a non-interactive run falls back automatically). Use --llm to require the
LLM — it then fails if the backend is unreachable instead of falling back — or
--no-llm to go straight to corpus fill. When LLM fill is used, content is generated
once per template
(--fill-variants variants each) in a single pass before rendering, and workers
sample from that pool — so LLM calls scale with templates * fill-variants, not with
the number of images. If the backend is unreachable, generation also falls back to
random corpus fill.
Default model — gemma3:4b @ temperature 0.7. This was chosen by evaluating
five local models over 20 random templates (see eval/): gemma3:4b produced 100%
valid Arabic, was the fastest (~4.2 s/doc), and is the smallest (3.3 GB) — a good fit
for an 8 GB GPU. aya-expanse:8b matched its quality but is ~20% slower; qwen2.5:7b
and qwen3.5:4b occasionally romanized Arabic (Latin transliteration). Fill diversity
comes from having many templates (each fill varies the slot values), not from a high
temperature, so 0.7 is used for stable quality. Any OpenAI-compatible model works
via --llm-model / --llm-base-url. See eval/README.md to re-run the comparison.
Re-running into an existing output directory appends: new IDs continue after existing ones and labels.json is preserved.
Examples:
# 1000 augmented training docs + 200 val
# (uses the LLM if the backend is up, else asks to fall back to corpus)
pagen dataset --train 1000 --val 200 -o data/
# Skip the LLM entirely, no prompt (offline / no Ollama)
pagen dataset --train 1000 --val 200 -o data/ --no-llm
# LLM fill via Ollama, no prompt (must be running: ollama serve)
pagen dataset --train 100 --val 20 --llm --llm-model gemma3:4b
# Clean (no augmentation), save txt and pdf alongside images
pagen dataset --train 50 --val 10 --pdf-only --keep-txt --keep-pdf -o data/ --no-llm
# LLM fill via any OpenAI-compatible provider
OPENAI_API_KEY=sk-... pagen dataset --train 100 --val 20 \
--llm --llm-base-url https://api.openai.com/v1 --llm-model gpt-4o-miniProduces a PNG + plain-text ground truth per document. No labels.json.
pagen eval -c 50 -o data/evalTemplates are generated once and reused across many dataset runs. This command is never invoked automatically by the dataset pipeline.
By default each document type yields --per-type 5 templates, produced in a single batched LLM call that assigns a distinct structural archetype per template (table, header fields, bullet list, numbered steps, prose, subsections). This gives the trained detector varied word-box layouts for the same document type, which independent single-shot calls do not: they collapse onto the model's one favored shape. Use --per-type 1 for the classic one-call-per-type behavior. The k templates for a type land as slug.md, slug_2.md, ...
# Generate 10 document types x 5 templates each (50 files) from the built-in pool
pagen templates --random 10 --llm --llm-model gemma3:4b
# One template per type (classic single-call behavior)
pagen templates --random 10 --per-type 1 --llm
# Generate specific types (5 templates each)
pagen templates "عقد عمل" "خطاب توصية" --llm
# From a file
pagen templates --file my_types.txt --llmThe photo background augmentation path needs to know where the paper is in each photo. Corners are detected automatically the first time and cached in paper_corners.json. The automatic detection is not perfect, so you can inspect and fix the cache with an interactive editor.
Each entry records its provenance ("source": "auto" or "user"). The cache is reconciled append-only: only newly-added images are auto-detected and saved; existing entries are never re-detected or overwritten, so corners you fix in the editor ("source": "user") are safe across dataset runs.
# Build / refresh the cache (runs automatically during augmented dataset gen)
pagen corners --scene-dir resources/images/scene
# Launch interactive editor to fix bad auto-detections
pagen corners --scene-dir resources/images/scene --edit
# Export overlay images to inspect detections
pagen corners --scene-dir resources/images/scene --visualize --out corners_vis/Editor controls: drag handle = move corner | n/→ = next | p/← = prev | r = re-detect | f = full-frame | s = save | q/Esc = save + quit
pagen visualize data/train --max 20 --save data/train/debug_vis/
pagen visualize data/train --show # requires matplotlibReports out-of-bounds polygons, label/polygon count mismatches, and empty labels.
API keys cannot be passed as CLI flags. Set the environment variable named by --api-key-env (default OPENAI_API_KEY):
export OPENAI_API_KEY=sk-...
# or put it in a .env file (auto-loaded if python-dotenv is installed):
echo "OPENAI_API_KEY=sk-..." > .envOllama requires no real key; the client sends a dummy value it ignores.