A FastAPI service for queued translation jobs over images and PDF documents. You submit a request (an image or a PDF plus a source/target language), it runs through a task pipeline — OCR or text-layer extraction, structural grouping, translation, and re-rendering the translated text back onto the page — and you poll for the result and its artifacts.
The service owns translation input/output and orchestration. It does not run
language models itself: text translation and the vision grouping hint are
delegated to a separate model pool (llm-pool) over HTTP, and OCR runs locally
via PaddleOCR.
- What It Does
- Repository Role
- Related Repositories
- Code Map
- API Surface
- Runtime Model
- Configuration
- Development
- Tests
- Regression Testing
- Deployment Notes
- License
- Accepts an uploaded image (
image/jpeg,image/png,image/webp) and a source/target language, and returns a translated image: the original text is erased and the translation is re-rendered in place, matching line geometry, size hierarchy, font family/weight, and alignment. - Runs the
translate_imagepipeline in stages:- OCR — PaddleOCR detects and recognizes text cells (with merge and upright re-recognition passes). OCR cells stay authoritative for text and bounding boxes.
- Grouping — a vision-language model returns a structural hint (reading order, hierarchy level, font, alignment); the aligner maps it back onto the OCR cells and builds translation units. A weak hint lowers quality but does not fail the job.
- Translation — units are translated through
llm-pool, with language-pair routing and an optional prompt from the prompt library. - Replacement — the renderer erases the source text and draws the translation back onto the image footprint.
- Accepts an uploaded PDF (
application/pdf) and translates it page by page (translate_pdf): each page is rasterized and run through the sametranslate_imagestages, then the translated pages are reassembled into a PDF on the original page sizes. Born-digital pages take their text from the PDF text layer instead of OCR; a per-page census records the page class (born-digital / scanned / hybrid), and every page keeps its own inspectable artifacts. - Supports re-translation (
retranslate_image): reuse a prior request's cached OCR/grouping and only re-run translation with a different prompt or target language — no OCR/VLM again. - Stores every stage as an inspectable artifact per request (grouping, segments, translation, debug overlays, the rendered output, and the raw model calls).
- Exposes a small prompt library (CRUD) of saved translation system prompts.
This repo owns:
- The HTTP API and the request lifecycle (submit → queue → run → poll).
- The job scheduler (FIFO queue, bounded concurrency, lifecycle records with TTL).
- The image pipeline stages (OCR, grouping/alignment, translation routing, re-placement rendering) and their artifacts.
- The document pipeline (PDF intake and page census, per-page rasterization and text-layer extraction, reassembly) that reuses the image stages per page.
It deliberately does not own:
- The translation or vision models — they run in
llm-pool(see Related Repositories). - Any client UI — callers are separate apps.
llm-pool— serves the translation and vision-language models over an HTTP/v1/responsesAPI.translation-servicescalls it for both the grouping hint and text translation; the model names are set in configuration. Without it, OCR still runs but translation fails.- Client apps — any frontend that submits requests to this service's
/v1API (for example a translation workbench, or a webapp's image/camera flow).
app/main.py is the composition root and ASGI entry (app.main:app). It wires
settings → runtime and registers the HTTP routes. Everything else is grouped by
concern:
app/
main.py # HTTP composition root + route registration
routes/ # one module per route family: requests, prompts, benchmark, image + pdf regression
core/ # settings, request/response schemas, helpers
runtime/ # job execution: FIFO queue, runner loop, lifecycle store
tasks/ # feature pipelines — one module per task (the readable flows)
translate_image.py
retranslate_image.py
translate_pdf.py
ocr/ # OCR component: paddle backend, segment model, overlay
layout/ # layout-region component: detector engine + document evidence
grouping/ # VLM hint + aligner -> translation units
translation/ # language-pair routing, llm-pool translate, prompt library
replacement/ # erase + re-render: geometry, fit, color, render
pdf/ # PDF intake: census, page raster, text-layer cells, assembly
benchmark/ # document-pair benchmark: measurement, scoring, run store
regression/ # fixture harnesses: shared page machinery, image + pdf document
To see how a feature works, open tasks/<task>.py — it reads as a recipe that
calls named stages.
All routes are versioned under /v1.
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/requests |
Submit a job. Multipart: request_json (a JSON object) + image_file (image tasks) or document_file (a PDF, task translate_pdf). Returns the lifecycle envelope with a request_id. |
GET |
/v1/requests/{id} |
Poll the request lifecycle (state, stage, timings, response, error). |
POST |
/v1/requests/{id}/cancel |
Request cancellation. |
POST |
/v1/requests/{id}/retranslate |
Re-translate a completed request's cached units with a new prompt/target language. |
POST |
/v1/requests/{id}/rerender |
Re-render a completed request's cached translations with new render flags (render_size_mode/erase_fill_mode/width_fit_mode/size_metric_mode/size_cohort_mode); no new translation. An image source re-renders its image; a document source re-renders every page from its own cached translations and reassembles the PDF. |
GET |
/v1/requests/{id}/artifacts/{name} |
Fetch a stored artifact (e.g. rendered.png, translation.json). |
GET POST |
/v1/prompts |
List / create saved prompts. |
GET PUT DELETE |
/v1/prompts/{id} |
Read / update / delete a saved prompt. |
GET |
/v1/completions |
Poll completed-request events (events + next_seq cursor). |
GET |
/v1/status |
Service status / health. |
GET |
/v1/benchmark/results |
Stored document-benchmark runs (the comparison matrix). |
GET |
/v1/benchmark/testset |
PDF testset documents available as benchmark sources. |
POST |
/v1/benchmark/run |
Measure + score a pair: {request_id} of a completed translate_pdf run, or an uploaded pair + system label. target_lang selects the OCR model that reads the rendered pages (default en); scores are only comparable within one language. |
GET |
/v1/benchmark/runs/{doc}/{system} |
Detail (per-page scores) of the latest stored run. |
GET |
/v1/benchmark/runs/{doc}/{system}/{run}/anchors |
Evidence behind the anchors axis: which digit anchors went missing/appeared, located at page + segment. |
GET |
/v1/benchmark/runs/{doc}/{system}/{run}/overlay/{side}/{page} |
Region-overlay render of a measured page. |
GET |
/v1/pdf-regression/fixtures |
Document-fixture inventory, with each fixture's frozen accepted score. |
GET |
/v1/pdf-regression/status |
What capturing a completed translate_pdf run would produce: the fixture name (testset document matched by content hash) and its existing fixtures. |
POST |
/v1/pdf-regression/capture |
Freeze a completed translate_pdf run as a document fixture; verifies the replay against the run before writing, optionally freezes the accepted score. |
POST |
/v1/pdf-regression/run |
Replay a document fixture and diff it against its snapshots; optional benchmark-on-replay against the accepted score. |
POST |
/v1/pdf-regression/accept |
Re-baseline a fixture from the current replay (refused when frozen inputs no longer reproduce). |
GET DELETE |
/v1/pdf-regression/fixtures/{name}/{lang}/{variant}/… |
Fixture artifacts (source/accepted/actual PDF, per-page snapshot/actual/diff PNGs, accepted scores) / delete a variant. |
request_json fields: task (translate_image | retranslate_image | rerender_image | translate_pdf),
source_lang_code (required for routing), target_lang_code, optional
translator_model, translator_mode (generic | translategemma),
grouping_model, translation_prompt / translation_prompt_id,
source_request_id (for re-translate), metadata, request_id.
Lifecycle state: queued → running → completed | failed |
cancelled (with cancel_requested in between).
Submit example:
curl -sS -X POST http://127.0.0.1:8030/v1/requests \
-F 'request_json={"task":"translate_image","source_lang_code":"en","target_lang_code":"nl"};type=application/json' \
-F 'image_file=@input.jpg;type=image/jpeg'- Requests are admitted to a FIFO queue (bounded by
scheduler.queue_limit) and executed by a fixed number of concurrent runner slots (scheduler.runner_slots). uvicorn runs without--reload, so code edits require a restart. - Each request gets a directory under the configured
work_rootholding the uploaded input and every stage artifact (grouping, segments, translation, debug overlays,rendered.png/output.png, and the rawllm_calls/). - Terminal records are retained in memory with a per-state TTL
(
scheduler.records_ttl_s) and capped atscheduler.records_max. - External dependency:
llm-poolatllm_pool.base_urlfor the grouping hint and translation calls.
Settings load from config/settings.json, with an optional config/local.json
overlaying per-host overrides (deep-merged, gitignored). The settings file path
can be overridden with the TRANSLATION_SERVICES_SETTINGS_PATH environment
variable.
Key sections (see app/core/config.py for all defaults):
service—host,port(default8030),log_level,work_root,prompts_root,warm_models_on_start(build the local GPU engines at startup instead of on the first request; default off, on for the deployed hosts).scheduler—runner_slots,image_reserved_slots(runner slots kept free for the interactive image class so a PDF job cannot hold up an image request),queue_limit,records_max,records_ttl_s.llm_pool—base_url,translator_model,translator_mode,grouping_model,request_timeout_s,max_inflight_per_model(bound on this service's outstanding calls to one pool model). The model fields are names the pool resolves; set them to models the pool actually serves.ocr—backend,language(empty = route the recognizer per image from the grouping hint),min_confidence,device(cpu/gpu:0),ocr_version, detection limits, and optional explicitdet_model/rec_model.inpaint—model_path(TorchScript checkpoint for the model-based erase fill, used byerase_fill_mode="inpaint"; GPU-only),pixel_budget_px(hard cap on the crop area fed to the model, bounds its VRAM use).pdf—analysis_dpi(render dpi for analysis and output),page_cap,page_concurrency(pages of one document translated at once, default4; a request may override it, clamped to twice this value).
translator_mode: generic uses a target-language prompt and auto-detects the
source; translategemma uses the dedicated model and needs source_lang_code.
Requires Python 3.11+.
python -m venv .venv
.venv/bin/pip install -e .
.venv/bin/python -m uvicorn app.main:app --host 127.0.0.1 --port 8030PaddleOCR / PaddleX are heavy dependencies; the first OCR run downloads model
weights. OCR can run on CPU or GPU via ocr.device. Translation requires a
reachable llm-pool.
.venv/bin/python -m pytest tests/tests/ covers the API surface (test_api.py), grouping/alignment
(test_grouping.py), re-placement rendering (test_replacement.py), and
translation routing (test_translate.py).
The testset grows as new cases are added, and a fix aimed at one image can change the grouping, alignment or render of others. Checking every image by hand after each change doesn't scale — so it gets skipped, and a regression slips through unnoticed.
Automating it isn't as simple as re-rendering and comparing, because the pipeline isn't fully deterministic: for the same image, two runs usually agree but can differ slightly — a different element alignment, or an equally valid translation. A plain re-render would flag that normal variation as a regression.
The fix is to freeze the non-deterministic stages and re-run the deterministic ones:
- Freeze the grouping hint (from the VLM), the OCR cells, and the translations.
- Re-run
parse_grouping_output→group_cells_into_units(align) →render_translated_imageon them.
With the code unchanged, that chain produces the same result every time, so any diff is a real code regression. The test pins the code, not the models. It's also fast: a replay makes no VLM or translation calls per image.
The render is compared by reading the image back with OCR — re-OCR — and checking the recognised text and box positions, not the raw pixels. That makes it a behavioural check, not a pixel-exact one. It's reliable because OCR is bit-stable: identical pixels re-OCR to identical text and boxes, so the comparison adds no noise. And it catches real changes — a no-op replay passes, a one-line render change (e.g. +2px erase pad) fails, and reverting it passes again.
| stage | in a regression run |
|---|---|
| VLM grouping hint | frozen (raw string) — hint_parser still re-runs |
| OCR cells | frozen (OCR is deterministic) |
alignment (grouping/align.py) |
re-runs — tested |
| translation | frozen — one approved variant, attached to the re-grouped units |
render (replacement/render.py) |
re-runs — tested |
Alignment and render are the deterministic stages where the bugs live; the hint parser runs too. Translation quality stays frozen, so it's out of scope here.
-
Alignment — compared exactly, field by field: each unit's ordered cells, which cells were ignored, and the render-relevant fields the aligner sets (per-member translate flag, font family/weight, level, alignment). A failure points at the exact unit and field.
-
Render — the re-OCR of the replayed image is checked two ways:
- text — every segment must be present with matching text. OCR is exact on identical pixels, so a change in how a line is split or grouped is caught.
- position — each matched segment must land within 3px.
Same machine and fonts make this effectively exact; the 3px only absorbs sub-pixel anti-aliasing.
A fixture freezes the exact result you approved in the workbench. After that:
- Replay one fixture or all of them; each gets a pass/fail against its snapshot, with per-stage timings (align, render, re-OCR).
- Re-baseline when a change to align or render is intended and correct.
Each fixture carries its own source image, so a replay never drifts against an
external file. Fixtures are organised per target language, and a language can hold
several variants (v1, v2, …) — each freezing a different valid hint or
translation, which aligns and renders differently. They're curated cases, not a
growing accept-set.
The same machinery covers translate_pdf at document level: a document fixture
is a set of page fixtures (identical schema) plus the frozen source PDF and
document checks — the census, each page's raster, and for born-digital pages an
exact re-extraction diff of the text-layer cells. Diffs in those frozen inputs
cannot be re-baselined in place; they require a fresh capture, because the
frozen hint and translations belong to the old derivation. A replay can also be
benchmarked against the fixture's frozen accepted score
(scripts/pdf_regress.py, the /v1/pdf-regression/* routes).
Full design — freeze boundary, schema, comparison rules, the measured variance study and the regression API endpoints: docs/regression-test-design.md; the document-level harness and the benchmark it composes with: docs/pdf-benchmark-regression-design.md.
A systemd user unit and start script live under deploy/systemd/ (the start
script reads the port from service.port or DEFAULT_PORT). See
deploy/systemd/README.md for install commands and the per-host venv override.
Render fonts are a prerequisite and are not vendored: the renderer loads
metric-compatible Latin faces (and Noto Sans KR for Korean) from a per-host
fonts directory; Han/Kana fonts are fetched lazily by PaddleX. A missing font
degrades gracefully (Latin falls back; CJK/Korean render as tofu), so fonts must
be provisioned per host for correct output. Exact filenames and install steps
are in deploy/systemd/README.md.
No license file is present in this repository.