ocr: Chandra, the document VLM that is asked for its layout (v0.3.24)

A fifth OCR engine. Chandra (datalab-to/chandra-ocr-2) is from the same lab as
Surya and is driven the way olmOCR is — one page image over an OpenAI chat
endpoint, three serving modes (model | vllm | server) — but its `ocr_layout`
prompt asks the model to answer as one <div> per layout block carrying
data-bbox (normalised 0-1000) and data-label. So it is the only engine here
that returns text, boxes, labelled regions, tables AND page metadata in one
pass, with no separate detector.

Why it was worth adding now: it is a DIFFERENT MODEL FAMILY from surya-2
(Qwen3-VL's tokenizer, against surya-2's own 65k WordLevel vocabulary) reached
over the same transport, at the same pixel ceiling, from the same rasterised
page. That makes it a cheap controlled experiment on the U+FFFD accent loss:
accents intact under chandra and lost under surya points at the model; both
losing them points back at the document's pixels, and then pdfimages -list is
the next thing to read.

Implementation notes:

- NO new pip dependency. The two prompts are mirrored verbatim from
  chandra/prompts.py (down to the Python repr of the allowed-tag list, which is
  trained-on wording), the input scaling is chandra's own scale_to_fit
  reimplemented, and the reply is split with stdlib html.parser rather than
  pulling bs4 and markdownify into the main venv for a tag split.
- Chandra's pixel cap is 3072x2048 = 6,291,456 — the SAME numbers as surya-2 —
  and it snaps to a 28-pixel grid for the Qwen vision tower. So chandra_dpi
  defaults to 250 for exactly the reason surya_dpi does. Boxes are
  de-normalised against the image the MODEL saw, and the size the caller
  rasterised is reported as meta.source_size when they differ: de-normalising
  against the pre-scaling size would put every box on a scaled page in the
  wrong place.
- A repetition loop answers 200. A VLM that loses its place repeats the last
  thing it saw until the token budget runs out, and the result looks like a
  successful page. chandra_max_retries (2) re-asks with a raised temperature —
  a greedy decode falls into the same loop twice — and a page that still loops
  is returned flagged with meta.repeat_detected rather than handed on silently.
- The parse never loses page. No blocks at all, content outside any block, an
  unclosed final div (routine at the token limit), a bbox that will not parse:
  each keeps the text. A bbox that cannot be read yields NO region rather than
  a full-page one, because a wrong box looks like a real answer.
- Table cells get a space, not a newline, so a table does not flatten to
  "VoceImporto".

License: narrower than Surya's GPL. The code is Apache-2.0 but the WEIGHTS are
a modified OpenRAIL-M — research, personal use and companies under $2M
funding/revenue, no use competing with Datalab's API. So it is gated on
chandra_accept_license, and the pod path differs from surya's on purpose:
asking a pod for surya by name IS the GPL acceptance, while chandra's is only
ever inherited from a decision already recorded in this install's config. A pod
cannot accept a licence on an operator's behalf.

lang= is accepted and does not steer it (language_aware: false), like surya-2
and olmOCR: 90+ languages, no language input. So it keeps ONE pool whatever is
asked — a language-keyed pool is a second set of loaded instances holding VRAM.

Wired through: engine registry, licence gate, per-engine DPI, the vLLM stop
path (its VRAM is the subprocess's, not a pool's), the capability catalogue
(which now reports boxes/regions/tables from chandra_layout, because with
layout off there genuinely are none), model-name aliasing, the automatic
fallback chain, pod planning + env seeding, the frontproxy's local-OCR check,
models.json capability advertisement, the pipeline step's engine list, admin
save/load and the Settings OCR card.

tests/test_ocr_chandra.py: 61 tests. 788 passed across the OCR/config/admin/
runpod selection with the same 10 pre-existing failures (broker_config x5,
studio_composed_surfaces x3, whisper_server x2).

test_ocr_fanout's catalogue test asserted a frozen engine set; it now asserts
against the registry, which is what "lists every engine" was always supposed to
mean.

Also fixed while in there: the Settings page used `parseInt(v) || default` for
numbers whose zero is meaningful, so a deliberate 0 saved as the default. New
_num() helper only falls back for an empty or unparseable field.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxYHWbCAjjjD7A1rhFKtaW
parent 2560d56b
......@@ -276,13 +276,18 @@ registered in the embedding model category.
### Document OCR
A dedicated OCR subsystem (`/v1/ocr`) — real OCR engines, **not** a VLM prompted to read,
plus one purpose-trained document VLM for the pages they struggle with:
plus two purpose-trained document VLMs for the pages they struggle with:
- **Engines**: `doctr` (in-process), `paddle` (PaddleOCR + PP-Structure layout/tables, isolated
venv), `surya` (opt-in, GPL; local, vLLM-served Surya-2, or an external llama-server) and
venv), `surya` (opt-in, GPL; local, vLLM-served Surya-2, or an external llama-server),
`olmocr` ([olmOCR-2](https://huggingface.co/allenai/olmOCR-2-7B-1025), Apache-2.0 — best
on old scans, maths and multi-column pages; served by one of your own models, coderai's
vLLM, or any OpenAI endpoint. Returns no bounding boxes.)
vLLM, or any OpenAI endpoint. Returns no bounding boxes.) and
`chandra` ([Chandra](https://huggingface.co/datalab-to/chandra-ocr-2) — a document VLM
that is *asked* for its layout, so unlike olmOCR it returns boxes, labelled regions and
tables in one pass, and being a different model family it is the useful second opinion on
a page surya reads badly. Opt-in: code Apache-2.0, **weights modified OpenRAIL-M** —
research, personal use and under-$2M companies, no competitive use.)
- **Input**: images or PDFs (rasterised at a configurable DPI), single or batch
- **Output**: full text plus per-page `lines[]` with bounding boxes and confidence,
`regions[]` (layout), `tables[]`, and `meta` where the engine reports page metadata
......
......@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here.
__version__ = "0.3.23"
__version__ = "0.3.24"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -4166,6 +4166,22 @@ def build_settings_dict(c, gpu_cards):
"olmocr_temperature": c.ocr.olmocr_temperature,
"olmocr_timeout": c.ocr.olmocr_timeout,
"olmocr_retry_rotation": c.ocr.olmocr_retry_rotation,
"chandra_enabled": c.ocr.chandra_enabled,
"chandra_accept_license": c.ocr.chandra_accept_license,
"chandra_instances": c.ocr.chandra_instances,
"chandra_serve": c.ocr.chandra_serve,
"chandra_model_id": c.ocr.chandra_model_id,
"chandra_model": c.ocr.chandra_model,
"chandra_server_url": c.ocr.chandra_server_url,
"chandra_api_key": c.ocr.chandra_api_key,
"chandra_dpi": c.ocr.chandra_dpi,
"chandra_layout": c.ocr.chandra_layout,
"chandra_include_furniture": c.ocr.chandra_include_furniture,
"chandra_max_tokens": c.ocr.chandra_max_tokens,
"chandra_temperature": c.ocr.chandra_temperature,
"chandra_top_p": c.ocr.chandra_top_p,
"chandra_max_retries": c.ocr.chandra_max_retries,
"chandra_timeout": c.ocr.chandra_timeout,
"detect_mode": c.ocr.detect_mode,
"detect_model_path": c.ocr.detect_model_path,
"detect_conf": c.ocr.detect_conf,
......@@ -4968,6 +4984,23 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
if "olmocr_temperature" in d: o.olmocr_temperature = _f("olmocr_temperature", o.olmocr_temperature, 0.0)
if "olmocr_timeout" in d: o.olmocr_timeout = _f("olmocr_timeout", o.olmocr_timeout, 1.0)
if "olmocr_retry_rotation" in d: o.olmocr_retry_rotation = bool(d["olmocr_retry_rotation"])
if "chandra_enabled" in d: o.chandra_enabled = bool(d["chandra_enabled"])
if "chandra_accept_license" in d: o.chandra_accept_license = bool(d["chandra_accept_license"])
if "chandra_instances" in d: o.chandra_instances = _i("chandra_instances", o.chandra_instances, 1)
if "chandra_serve" in d: o.chandra_serve = (d.get("chandra_serve") or "model").strip().lower()
if "chandra_model_id" in d: o.chandra_model_id = (d.get("chandra_model_id") or "").strip()
if "chandra_model" in d: o.chandra_model = (d.get("chandra_model") or "").strip()
if "chandra_server_url" in d: o.chandra_server_url = (d.get("chandra_server_url") or "").strip()
if "chandra_api_key" in d: o.chandra_api_key = (d.get("chandra_api_key") or "").strip()
if "chandra_dpi" in d: o.chandra_dpi = _i("chandra_dpi", o.chandra_dpi, 0)
if "chandra_layout" in d: o.chandra_layout = bool(d["chandra_layout"])
if "chandra_include_furniture" in d: o.chandra_include_furniture = bool(d["chandra_include_furniture"])
if "chandra_max_tokens" in d: o.chandra_max_tokens = _i("chandra_max_tokens", o.chandra_max_tokens, 64)
if "chandra_temperature" in d: o.chandra_temperature = _f("chandra_temperature", o.chandra_temperature, 0.0)
if "chandra_top_p" in d: o.chandra_top_p = _f("chandra_top_p", o.chandra_top_p, 0.0)
# 0 is meaningful: accept the first reply however it looks.
if "chandra_max_retries" in d: o.chandra_max_retries = _i("chandra_max_retries", o.chandra_max_retries, 0)
if "chandra_timeout" in d: o.chandra_timeout = _f("chandra_timeout", o.chandra_timeout, 1.0)
# detection
if "detect_mode" in d: o.detect_mode = (d.get("detect_mode") or "off").strip()
if "detect_model_path" in d: o.detect_model_path = (d.get("detect_model_path") or "").strip()
......
......@@ -1096,6 +1096,7 @@
<option value="doctr">docTR</option>
<option value="surya">Surya</option>
<option value="olmocr">olmOCR-2 (VLM)</option>
<option value="chandra">Chandra (VLM, boxes)</option>
</select>
</div>
<div class="form-group" style="flex:1;min-width:110px">
......@@ -1263,6 +1264,44 @@
<div class="form-group" style="flex:1;min-width:110px"><label class="form-label">Timeout (s)</label><input type="number" id="s-ocr-olmocr-timeout" class="form-input" min="1" placeholder="300"></div>
</div>
<div class="card-title" style="font-size:13px;margin-top:.4rem">Chandra <span class="muted" style="font-weight:400">(document VLM, with boxes)</span></div>
<p class="form-hint" style="margin:0 0 .4rem"><a href="https://huggingface.co/datalab-to/chandra-ocr-2" target="_blank" rel="noopener">datalab-to/chandra-ocr-2</a> is from the same lab as Surya and reads a whole page at once like olmOCR &mdash; but it is <b>asked</b> for layout, so it returns one block per region <b>with a bounding box and a label</b> (Text, Table, Section-Header, Figure, Form&hellip;), which olmOCR cannot. Tables keep their HTML, maths comes back as KaTeX, chemistry as SMILES. It is also a <b>different model family</b> from Surya-2 (Qwen3-VL, its own tokenizer), which makes it the useful second opinion on a page Surya reads badly &mdash; including one that loses its accents.</p>
<p class="form-hint" style="margin:0 0 .4rem;border-left:3px solid var(--warning,#c58b00);padding-left:.6rem"><b>Licence.</b> The code is Apache-2.0 but the <b>weights are a modified OpenRAIL-M</b>: free for research, personal use and companies under <b>$2M</b> funding/revenue, and <b>not</b> for use competing with Datalab's own API. Commercial self-hosting beyond that needs a licence from Datalab. Nothing loads until you accept below &mdash; this is a narrower grant than Surya's GPL, so read it before ticking it.</p>
<div class="form-row" style="display:flex;gap:1rem;flex-wrap:wrap;align-items:center">
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="s-ocr-chandra-enabled"><span>Enable</span></label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="s-ocr-chandra-accept-license"><span>I accept the Chandra weights licence</span></label>
<div class="form-group" style="flex:1;min-width:150px"><label class="form-label">Served by</label>
<select id="s-ocr-chandra-serve" class="form-input">
<option value="model">coderai model (any GPU)</option>
<option value="vllm">coderai vLLM (CUDA)</option>
<option value="server">external OpenAI server</option>
</select>
</div>
<div class="form-group" style="flex:1;min-width:110px"><label class="form-label">Instances</label><input type="number" id="s-ocr-chandra-instances" class="form-input" min="1" placeholder="1"></div>
</div>
<div class="form-row" style="display:flex;gap:1rem;flex-wrap:wrap">
<div class="form-group" style="flex:2;min-width:200px"><label class="form-label">Model id <span class="muted">(coderai model mode)</span></label><input type="text" id="s-ocr-chandra-model-id" class="form-input" placeholder="chandra (a vision model in models.json)"></div>
<div class="form-group" style="flex:2;min-width:200px"><label class="form-label">HF checkpoint <span class="muted">(vLLM / served name)</span></label><input type="text" id="s-ocr-chandra-model" class="form-input" placeholder="datalab-to/chandra-ocr-2"></div>
</div>
<div class="form-row" style="display:flex;gap:1rem;flex-wrap:wrap">
<div class="form-group" style="flex:2;min-width:200px"><label class="form-label">Server URL <span class="muted">(external mode)</span></label><input type="text" id="s-ocr-chandra-server-url" class="form-input" placeholder="http://127.0.0.1:8000/v1"></div>
<div class="form-group" style="flex:1;min-width:140px"><label class="form-label">API key <span class="muted">(opt)</span></label><input type="password" id="s-ocr-chandra-api-key" class="form-input" placeholder=""></div>
</div>
<div class="form-row" style="display:flex;gap:1rem;flex-wrap:wrap;align-items:center">
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="s-ocr-chandra-layout"><span>Ask for layout blocks</span></label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer"><input type="checkbox" id="s-ocr-chandra-include-furniture"><span>Keep headers &amp; footers</span></label>
<div class="form-group" style="flex:1;min-width:110px"><label class="form-label">Rasterise DPI</label><input type="number" id="s-ocr-chandra-dpi" class="form-input" min="0" placeholder="250"></div>
<div class="form-group" style="flex:1;min-width:120px"><label class="form-label">Max tokens / page</label><input type="number" id="s-ocr-chandra-max-tokens" class="form-input" min="64" placeholder="12384"></div>
</div>
<p class="form-hint" style="margin:0 0 .3rem">Turning <b>layout blocks</b> off asks for plain HTML instead: faster and shorter, but then there are no boxes and no regions at all, which is the main reason to pick this engine. <b>Headers &amp; footers</b> on keeps page furniture in the text &mdash; worth it for judicial documents, whose header carries the court and the case number. <b>DPI</b> 0 follows the shared setting; Chandra's input is capped at 3072&times;2048 = 6.29&nbsp;Mpixels, the same ceiling as Surya-2, which an A4 page reaches at about 255&nbsp;DPI &mdash; above that the page is scaled down before the model sees it.</p>
<div class="form-row" style="display:flex;gap:1rem;flex-wrap:wrap">
<div class="form-group" style="flex:1;min-width:110px"><label class="form-label">Temperature</label><input type="number" id="s-ocr-chandra-temperature" class="form-input" min="0" max="2" step="0.1" placeholder="0"></div>
<div class="form-group" style="flex:1;min-width:110px"><label class="form-label">Top-p</label><input type="number" id="s-ocr-chandra-top-p" class="form-input" min="0" max="1" step="0.05" placeholder="0.1"></div>
<div class="form-group" style="flex:1;min-width:130px"><label class="form-label">Retries on a loop</label><input type="number" id="s-ocr-chandra-max-retries" class="form-input" min="0" placeholder="2"></div>
<div class="form-group" style="flex:1;min-width:110px"><label class="form-label">Timeout (s)</label><input type="number" id="s-ocr-chandra-timeout" class="form-input" min="1" placeholder="300"></div>
</div>
<p class="form-hint" style="margin:0 0 .3rem">A vision model that loses its place on a page does not fail &mdash; it repeats the last thing it saw until it runs out of tokens, and answers <code>200</code> with a page that looks plausible. <b>Retries on a loop</b> re-asks when the reply ends in a repetition, raising the temperature each time, because a greedy decode falls into the same loop twice. If it still loops after the last retry the page is returned anyway with <code>repeat_detected</code> in its metadata, so you can see it rather than be handed it quietly.</p>
<div class="card-title" style="font-size:13px;margin-top:.4rem">Stamp / signature detection</div>
<div class="form-row" style="display:flex;gap:1rem;flex-wrap:wrap">
<div class="form-group" style="flex:1;min-width:150px">
......@@ -2182,6 +2221,22 @@ async function loadSettings(){
document.getElementById('s-ocr-olmocr-max-tokens').value = ocr.olmocr_max_tokens ?? 4096;
document.getElementById('s-ocr-olmocr-temperature').value = ocr.olmocr_temperature ?? 0.1;
document.getElementById('s-ocr-olmocr-timeout').value = ocr.olmocr_timeout ?? 300;
document.getElementById('s-ocr-chandra-enabled').checked = !!ocr.chandra_enabled;
document.getElementById('s-ocr-chandra-accept-license').checked = !!ocr.chandra_accept_license;
document.getElementById('s-ocr-chandra-serve').value = ocr.chandra_serve ?? 'model';
document.getElementById('s-ocr-chandra-instances').value = ocr.chandra_instances ?? 1;
document.getElementById('s-ocr-chandra-model-id').value = ocr.chandra_model_id ?? '';
document.getElementById('s-ocr-chandra-model').value = ocr.chandra_model ?? '';
document.getElementById('s-ocr-chandra-server-url').value = ocr.chandra_server_url ?? '';
document.getElementById('s-ocr-chandra-api-key').value = ocr.chandra_api_key ?? '';
document.getElementById('s-ocr-chandra-dpi').value = ocr.chandra_dpi ?? 250;
document.getElementById('s-ocr-chandra-layout').checked = ocr.chandra_layout !== false;
document.getElementById('s-ocr-chandra-include-furniture').checked = ocr.chandra_include_furniture !== false;
document.getElementById('s-ocr-chandra-max-tokens').value = ocr.chandra_max_tokens ?? 12384;
document.getElementById('s-ocr-chandra-temperature').value = ocr.chandra_temperature ?? 0;
document.getElementById('s-ocr-chandra-top-p').value = ocr.chandra_top_p ?? 0.1;
document.getElementById('s-ocr-chandra-max-retries').value = ocr.chandra_max_retries ?? 2;
document.getElementById('s-ocr-chandra-timeout').value = ocr.chandra_timeout ?? 300;
document.getElementById('s-ocr-detect-mode').value = ocr.detect_mode ?? 'off';
document.getElementById('s-ocr-detect-model').value = ocr.detect_model_path ?? '';
document.getElementById('s-ocr-detect-conf').value = ocr.detect_conf ?? 0.35;
......@@ -2197,6 +2252,17 @@ async function loadSettings(){
async function saveSettings(silent){
const strOrNull = id => document.getElementById(id).value.trim() || null;
// A number field whose ZERO is meaningful. `parseInt(v) || dflt` turns a
// deliberate 0 into the default, which is wrong wherever 0 is a setting rather
// than an absence (chandra_dpi 0 = follow the shared DPI, temperature 0 =
// greedy, retries 0 = accept the first reply). Only an EMPTY or unparseable
// field falls back here.
const _num = (id, dflt, parse) => {
const raw = (document.getElementById(id).value || '').trim();
if (!raw) return dflt;
const v = parse(raw);
return Number.isFinite(v) ? v : dflt;
};
const data = {
admin: {
forward_auth: {
......@@ -2438,6 +2504,25 @@ async function saveSettings(silent){
olmocr_max_tokens: parseInt(document.getElementById('s-ocr-olmocr-max-tokens').value) || 4096,
olmocr_temperature: parseFloat(document.getElementById('s-ocr-olmocr-temperature').value) || 0.1,
olmocr_timeout: parseFloat(document.getElementById('s-ocr-olmocr-timeout').value) || 300,
chandra_enabled: document.getElementById('s-ocr-chandra-enabled').checked,
chandra_accept_license: document.getElementById('s-ocr-chandra-accept-license').checked,
chandra_serve: document.getElementById('s-ocr-chandra-serve').value || 'model',
chandra_instances: parseInt(document.getElementById('s-ocr-chandra-instances').value) || 1,
chandra_model_id: document.getElementById('s-ocr-chandra-model-id').value.trim(),
chandra_model: document.getElementById('s-ocr-chandra-model').value.trim(),
chandra_server_url: document.getElementById('s-ocr-chandra-server-url').value.trim(),
chandra_api_key: document.getElementById('s-ocr-chandra-api-key').value.trim(),
// 0 is a real value for these three (DPI 0 = follow the shared setting,
// temperature 0 = greedy, retries 0 = accept the first reply), so `|| default`
// would silently overwrite a deliberate zero with the default.
chandra_dpi: _num('s-ocr-chandra-dpi', 250, parseInt),
chandra_layout: document.getElementById('s-ocr-chandra-layout').checked,
chandra_include_furniture: document.getElementById('s-ocr-chandra-include-furniture').checked,
chandra_max_tokens: parseInt(document.getElementById('s-ocr-chandra-max-tokens').value) || 12384,
chandra_temperature: _num('s-ocr-chandra-temperature', 0, parseFloat),
chandra_top_p: parseFloat(document.getElementById('s-ocr-chandra-top-p').value) || 0.1,
chandra_max_retries: _num('s-ocr-chandra-max-retries', 2, parseInt),
chandra_timeout: parseFloat(document.getElementById('s-ocr-chandra-timeout').value) || 300,
detect_mode: document.getElementById('s-ocr-detect-mode').value || 'off',
detect_model_path: document.getElementById('s-ocr-detect-model').value.trim(),
detect_conf: parseFloat(document.getElementById('s-ocr-detect-conf').value) || 0.35,
......
......@@ -138,7 +138,7 @@ STEP_PARAMS = {
"audio_cleanup": [("audio","ref","Source audio"),("noise_reduction","checkbox","Reduce background noise"),("normalize","checkbox","Normalize levels"),("remove_hum","checkbox","Remove hum"),("repair_clicks","checkbox","Repair clicks")],
"voice_clone": [("text","textarea","Text to synthesize"),("voice_name","text","Voice profile name"),("ref_text","text","Reference transcript"),("speed","number","Speed","1.0")],
"voice_convert": [("source_audio","ref","Source audio"),("voice_name","text","Voice profile name"),("f0_condition","checkbox","Singing mode"),("pitch_shift","number","Pitch shift","0"),("diffusion_steps","number","Steps","10")],
"ocr": [("image","ref","Document image/PDF ({{stepN.url}})"),("engine","select:paddle|doctr|surya|olmocr","OCR engine","paddle"),("detect","select:off|layout|detector|both","Stamp/signature detect","off"),("structured","checkbox","Structured JSON extraction")],
"ocr": [("image","ref","Document image/PDF ({{stepN.url}})"),("engine","select:paddle|doctr|surya|olmocr|chandra","OCR engine","paddle"),("detect","select:off|layout|detector|both","Stamp/signature detect","off"),("structured","checkbox","Structured JSON extraction")],
}
......
......@@ -561,7 +561,7 @@ def _remote_catalogue(url: str, api_key: str) -> list:
#: OCR engines are selected by name, not registered in models.json like a model.
#: Without this an OCR test answered "'surya' is not in the model list, so
#: nothing can say where it should run" — true of the catalogue, and useless.
_OCR_ENGINES = ("paddle", "doctr", "surya", "olmocr")
_OCR_ENGINES = ("paddle", "doctr", "surya", "olmocr", "chandra")
def _describe(model: str):
......
......@@ -18,8 +18,10 @@
POST /v1/ocr — OCR an uploaded image or PDF with a purpose-built OCR engine
(PaddleOCR / docTR / Surya), returning faithful text plus per-line boxes and layout.
Those three are NOT vision LLMs; ``engine=olmocr`` additionally offers olmOCR-2, a
document VLM for pages the detector-based engines read badly (it returns no boxes). Structured-JSON extraction and stamp/signature detection are layered
Those three are NOT vision LLMs. Two document VLMs are available for pages the
detector-based engines read badly: ``engine=olmocr`` (olmOCR-2, no boxes at all) and
``engine=chandra`` (Chandra, which is asked for layout and so returns boxes and labelled
regions as well). Structured-JSON extraction and stamp/signature detection are layered
on in later milestones (fields already accepted, no-op until then).
"""
......@@ -61,7 +63,7 @@ def _ocr_config():
@router.post("/v1/ocr", summary="OCR an image or PDF with a dedicated OCR engine")
async def create_ocr(
file: UploadFile = File(...),
engine: Optional[str] = Form(None), # paddle|doctr|surya|olmocr (default from config)
engine: Optional[str] = Form(None), # paddle|doctr|surya|olmocr|chandra (default from config)
dpi: Optional[int] = Form(None), # PDF rasterisation DPI
lang: Optional[str] = Form(None), # document language; omit or "auto" to let the engine decide
structured: Optional[bool] = Form(False), # structured JSON extraction via text model
......
......@@ -980,13 +980,13 @@ def volume_env(mount: str) -> dict:
def pod_ocr_engine(requested: str) -> str:
"""The OCR engine a pod will actually serve for a request naming ``requested``.
Mirrors the env a pod is launched with: surya needs a VLM server the image
does not carry, so a pod serves docTR for it. The gateway uses this to
rewrite the request's own `engine` field, because the pod honours what the
request asks for over its configured default.
Mirrors the env a pod is launched with: the VLM engines (surya, olmOCR,
chandra) need a model server the image does not carry, so a pod serves docTR
for them. The gateway uses this to rewrite the request's own `engine` field,
because the pod honours what the request asks for over its configured default.
"""
r = (requested or "").strip().lower()
if r in ("surya", "olmocr"):
if r in ("surya", "olmocr", "chandra"):
return "doctr"
return r if r in ("paddle", "doctr") else "doctr"
......@@ -1001,6 +1001,16 @@ def _surya_accepted() -> bool:
return False
def _chandra_accepted() -> bool:
"""Whether this deployment has accepted Chandra's weights licence."""
try:
from codai.admin.routes import config_manager
return bool(getattr(getattr(getattr(config_manager, "config", None),
"ocr", None), "chandra_accept_license", False))
except Exception:
return False
def _ocr_cfg():
"""This install's OcrConfig, or None. The pod has no config of its own — every
OCR setting it runs with is seeded from here."""
......@@ -1014,7 +1024,8 @@ def _ocr_cfg():
def _ocr_external_server(engine: str):
"""The off-box OpenAI-compatible endpoint configured for a VLM OCR engine, or "".
This is what decides whether a pod can actually serve surya/olmOCR. Both are VLMs
This is what decides whether a pod can actually serve surya/olmOCR/chandra. All
three are VLMs
and need a model server: locally they ride coderai's own vLLM, which a pod has
none of, and baking one in would mean a SECOND torch in the image (the vLLM venv
alone is ~10.5 GB against a ~12 GB ceiling). But in the served modes the engine
......@@ -1027,6 +1038,10 @@ def _ocr_external_server(engine: str):
mode = str(getattr(o, "olmocr_serve", "") or "").strip().lower()
url = str(getattr(o, "olmocr_server_url", "") or "").strip()
return url if (mode == "server" and url) else ""
if engine == "chandra":
mode = str(getattr(o, "chandra_serve", "") or "").strip().lower()
url = str(getattr(o, "chandra_server_url", "") or "").strip()
return url if (mode == "server" and url) else ""
if engine == "surya":
mode = str(getattr(o, "surya_serve", "") or "").strip().lower()
url = str(getattr(o, "surya_server_url", "") or "").strip()
......@@ -1125,12 +1140,13 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
_cands = [str((entry or {}).get("alias") or "").strip().lower(),
str(served or "").strip().lower(),
str((entry or {}).get("path") or "").strip().lower()]
wanted = next((c for c in _cands if c in ("paddle", "doctr", "surya", "olmocr")),
wanted = next((c for c in _cands
if c in ("paddle", "doctr", "surya", "olmocr", "chandra")),
_cands[-1])
# Remember what was ASKED for: the licence decision below is made by asking
# for surya by name, and that has to survive a downgrade to docTR.
_asked = wanted
if wanted in ("surya", "olmocr"):
if wanted in ("surya", "olmocr", "chandra"):
# Surya 0.22 is a VLM ("surya-ocr-2") and olmOCR-2 is one outright, not a
# self-contained OCR library: both need a model server. Locally they ride
# coderai's own vLLM engine, which a pod has none of, and baking one in
......@@ -1154,6 +1170,14 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
_val = str(getattr(_o, _a, "") or "").strip()
if _val:
env[_v] = _val
elif wanted == "chandra":
env["CODERAI_OCR_CHANDRA_SERVE"] = "server"
env["CODERAI_OCR_CHANDRA_SERVER_URL"] = _srv
for _v, _a in (("CODERAI_OCR_CHANDRA_MODEL", "chandra_model"),
("CODERAI_OCR_CHANDRA_MODEL_ID", "chandra_model_id")):
_val = str(getattr(_o, _a, "") or "").strip()
if _val:
env[_v] = _val
else:
env["CODERAI_OCR_SURYA_SERVE"] = "llamacpp"
env["CODERAI_OCR_SURYA_SERVER_URL"] = _srv
......@@ -1165,7 +1189,7 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
f"ocr.{wanted}_server_url and the served mode) — this OCR "
f"request will be served by docTR", flush=True)
wanted = "doctr"
if wanted not in ("paddle", "doctr", "surya", "olmocr"):
if wanted not in ("paddle", "doctr", "surya", "olmocr", "chandra"):
# The pod images ship docTR, the only engine that runs in-process.
wanted = "doctr"
env["CODERAI_OCR_DEFAULT_ENGINE"] = wanted
......@@ -1189,6 +1213,14 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
# the decision made HERE — it cannot make it for itself, and asking
# for surya by name is that decision.
env["CODERAI_OCR_SURYA_ACCEPT_LICENSE"] = "1"
if _asked == "chandra" and _chandra_accepted():
# Chandra's weights are a modified OpenRAIL-M, on NARROWER terms than
# Surya's GPL (no competitive use against Datalab's API, $2M ceiling).
# So asking for it by name is NOT the acceptance — unlike surya above,
# this one is only ever inherited from a decision already recorded in
# this install's config. A pod cannot accept a licence on an
# operator's behalf.
env["CODERAI_OCR_CHANDRA_ACCEPT_LICENSE"] = "1"
eng = engine_pod_backend(entry or {}, mcfg)
if eng:
......
......@@ -886,6 +886,10 @@ class OcrConfig:
best transcription fidelity on hard pages (old scans, maths, tables, multi-column),
but NO bounding boxes. Served through coderai's own model manager, its vLLM
backend, or any OpenAI-compatible endpoint (``olmocr_serve``).
- ``chandra`` — Chandra (Datalab; LICENSE-GATED — code Apache-2.0 but the WEIGHTS are
a modified OpenRAIL-M): also a document VLM, but one that returns layout blocks WITH
boxes and labels in a single pass, and from a different model family than surya-2 —
the second opinion on a page surya reads badly (``chandra_serve``).
Heavy OCR dependencies are installed out of band (optional) so the base image
stays lean. Concurrency on a single GPU comes from loading multiple instances of
......@@ -897,7 +901,7 @@ class OcrConfig:
stamp/signature flags (``detect_*``).
"""
enabled: bool = False
default_engine: str = "paddle" # paddle|doctr|surya|olmocr
default_engine: str = "paddle" # paddle|doctr|surya|olmocr|chandra
dpi: int = 200 # PDF rasterisation DPI
# Per-engine PDF rasterisation DPI (0 = use `dpi` above). Surya-2 gets its own
# because its input is capped at 3072x2048 = 6.29 Mpixels: `scale_to_fit` in
......@@ -932,7 +936,7 @@ class OcrConfig:
# page it was mid-way through. 0 = tear down immediately.
evict_drain_timeout_s: float = 60.0
# The STARTING share of the card a VLM OCR engine's own vLLM instance asks for
# (surya in vllm mode, olmocr in vllm mode); 0 = use vllm.gpu_memory_utilization.
# (surya, olmocr or chandra in vllm mode); 0 = use vllm.gpu_memory_utilization.
# It is a fraction of the card's TOTAL memory and has to cover the engine's whole
# footprint, not just its weights: measured live, surya-ocr-2 on a 24 GB 3090 needs
# ~10.9 GB before a single KV block (weights 1.37 + CUDA graphs 0.05 + ~9.4 GB of
......@@ -955,7 +959,7 @@ class OcrConfig:
# When the chosen engine cannot serve a document, try these engines instead rather
# than failing the request: a 503 means a document nobody read, and that is strictly
# worse than a transcription from the second-best engine. "auto" = every other
# ENABLED engine, fewest-ways-to-fail first (paddle, doctr, olmocr, surya);
# ENABLED engine, fewest-ways-to-fail first (paddle, doctr, olmocr, chandra, surya);
# "off"/"none" = no fallback (fail with the original error); or an explicit
# comma/space separated order.
fallback_engines: str = "auto"
......@@ -1050,6 +1054,49 @@ class OcrConfig:
olmocr_timeout: float = 300.0 # per-page ceiling on the model call
olmocr_retry_rotation: bool = True # re-ask once on a page the model reports rotated
# --- Chandra (Datalab; code Apache-2.0, WEIGHTS modified OpenRAIL-M) --- a document
# VLM like olmOCR, but its `ocr_layout` prompt returns one <div> per layout block with
# data-bbox (normalised 0-1000) and data-label, so unlike olmOCR it gives boxes AND
# labelled regions in a single pass. Qwen3-VL family, so a different tokenizer and
# different failure modes from surya-2 — which makes it the second opinion worth
# having on a page surya cannot read. See codai/ocr/chandra.py.
chandra_enabled: bool = False
# LICENSE-GATED, and on NARROWER terms than Surya's GPL: the weights are free for
# research, personal use and companies under $2M funding/revenue, and may not be used
# competitively with Datalab's own API. Commercial self-hosting needs a licence from
# Datalab. Nothing loads until this is set deliberately.
chandra_accept_license: bool = False
chandra_instances: int = 1 # in-flight pages against the serving model
# Served exactly like olmOCR: "model" (coderai's own model manager — the only mode
# that works on a GPU-less box, including one whose job is renting a pod), "vllm"
# (coderai's vLLM backend on its own instance), "server" (an endpoint already up).
chandra_serve: str = "model" # model | vllm | server
chandra_model_id: str = "" # ("model" mode) a vision model id from models.json
chandra_model: str = "datalab-to/chandra-ocr-2" # ("vllm"/"server") HF id / served name
chandra_server_url: str = "" # ("server" mode) OpenAI base URL of that model
chandra_api_key: str = "" # ("server" mode) bearer token, if it wants one
# Chandra's input is capped at 3072x2048 = 6.29 Mpixels — the SAME ceiling as surya-2
# (chandra/model/util.py `scale_to_fit`, same numbers), reached by an A4 page at about
# 255 DPI. Above it the page is downscaled before the model sees it, so rasterising
# higher costs time and buys nothing. 250 saturates A4; see `surya_dpi`.
chandra_dpi: int = 250
# Ask for layout blocks (the `ocr_layout` prompt) rather than plain HTML. On by
# default because it is the whole reason to choose this engine over olmOCR: with it
# off the reply is one unlocated blob and every bbox comes back empty.
chandra_layout: bool = True
# Keep Page-Header/Page-Footer blocks in the text. On: a judicial document's header
# carries the court, the section and the case number.
chandra_include_furniture: bool = True
chandra_max_tokens: int = 12384 # chandra's own MAX_OUTPUT_TOKENS; HTML runs long
chandra_temperature: float = 0.0 # chandra's own default: greedy
chandra_top_p: float = 0.1
# A VLM that loses its place on a page does not fail — it repeats the last thing it
# saw until it runs out of tokens, and answers 200 with a plausible-looking page. Each
# retry raises the temperature (a greedy decode falls into the same loop twice), which
# is what chandra's own client does. 0 = accept the first reply whatever it looks like.
chandra_max_retries: int = 2
chandra_timeout: float = 300.0 # per-page ceiling on the model call
# --- stamp / signature detection --- [O3]
detect_mode: str = "off" # off|layout|detector|both
detect_model_path: str = "" # YOLO signature/stamp weights (detector/both modes)
......@@ -1682,6 +1729,22 @@ class ConfigManager:
"olmocr_temperature": self.config.ocr.olmocr_temperature,
"olmocr_timeout": self.config.ocr.olmocr_timeout,
"olmocr_retry_rotation": self.config.ocr.olmocr_retry_rotation,
"chandra_enabled": self.config.ocr.chandra_enabled,
"chandra_accept_license": self.config.ocr.chandra_accept_license,
"chandra_instances": self.config.ocr.chandra_instances,
"chandra_serve": self.config.ocr.chandra_serve,
"chandra_model_id": self.config.ocr.chandra_model_id,
"chandra_model": self.config.ocr.chandra_model,
"chandra_server_url": self.config.ocr.chandra_server_url,
"chandra_api_key": self.config.ocr.chandra_api_key,
"chandra_dpi": self.config.ocr.chandra_dpi,
"chandra_layout": self.config.ocr.chandra_layout,
"chandra_include_furniture": self.config.ocr.chandra_include_furniture,
"chandra_max_tokens": self.config.ocr.chandra_max_tokens,
"chandra_temperature": self.config.ocr.chandra_temperature,
"chandra_top_p": self.config.ocr.chandra_top_p,
"chandra_max_retries": self.config.ocr.chandra_max_retries,
"chandra_timeout": self.config.ocr.chandra_timeout,
"detect_mode": self.config.ocr.detect_mode,
"detect_model_path": self.config.ocr.detect_model_path,
"detect_conf": self.config.ocr.detect_conf,
......@@ -1762,7 +1825,7 @@ class ConfigManager:
# the three default to off. Enabling the subsystem alone got a pod as
# far as "OCR engine 'surya' is not enabled" — a second refusal from
# a second flag, one round later.
for name in ("paddle", "doctr", "surya", "olmocr"):
for name in ("paddle", "doctr", "surya", "olmocr", "chandra"):
if _flag(f"CODERAI_OCR_{name.upper()}_ENABLED"):
setattr(self.config.ocr, f"{name}_enabled", True)
# Where that engine's interpreter lives. A pod image that ships
......@@ -1785,6 +1848,10 @@ class ConfigManager:
("CODERAI_OCR_SURYA_MODEL", "surya_model"),
("CODERAI_OCR_SURYA_MODEL_ID", "surya_model_id"),
("CODERAI_OCR_SURYA_SERVER_URL", "surya_server_url"),
("CODERAI_OCR_CHANDRA_SERVE", "chandra_serve"),
("CODERAI_OCR_CHANDRA_MODEL", "chandra_model"),
("CODERAI_OCR_CHANDRA_MODEL_ID", "chandra_model_id"),
("CODERAI_OCR_CHANDRA_SERVER_URL", "chandra_server_url"),
("CODERAI_OCR_FALLBACK_ENGINES", "fallback_engines")):
_val = (_os.environ.get(_env) or "").strip()
if _val:
......@@ -1793,6 +1860,11 @@ class ConfigManager:
# Surya is GPL and gated on an explicit acceptance. The pod
# inherits the decision made here; it cannot make it itself.
self.config.ocr.surya_accept_license = True
if _flag("CODERAI_OCR_CHANDRA_ACCEPT_LICENSE"):
# Chandra's weights are a modified OpenRAIL-M, and on narrower
# terms than Surya's GPL. Same rule: the pod inherits the
# decision made here and cannot make it itself.
self.config.ocr.chandra_accept_license = True
# The native engines — ds4, colibri, k3 — ship disabled for the same
# reason OCR does, and an engines pod is rented to run exactly one of
......
......@@ -1188,6 +1188,12 @@ class FrontProxy:
external = mode == "server" and bool(
(getattr(o, "olmocr_server_url", "") or "").strip())
local.append(not external)
if (getattr(o, "chandra_enabled", False)
and getattr(o, "chandra_accept_license", False)):
mode = (getattr(o, "chandra_serve", "model") or "model").strip().lower()
external = mode == "server" and bool(
(getattr(o, "chandra_server_url", "") or "").strip())
local.append(not external)
return bool(local) and not any(local)
def _resident_on(self, engine, key: str) -> bool:
......
......@@ -76,7 +76,7 @@ class ModelCapabilities:
# Retrieval / document
reranking: bool = False # cross-encoder query↔document reranking (bge-reranker, …)
ocr: bool = False # optical character recognition (paddle/doctr/surya/olmocr)
ocr: bool = False # optical character recognition (paddle/doctr/surya/olmocr/chandra)
def to_list(self) -> List[str]:
out = []
......
......@@ -5807,6 +5807,9 @@ class MultiModelManager:
_ocr_engines.append("surya")
if getattr(ocr, "olmocr_enabled", False):
_ocr_engines.append("olmocr")
if (getattr(ocr, "chandra_enabled", False)
and getattr(ocr, "chandra_accept_license", False)):
_ocr_engines.append("chandra")
for _eng in _ocr_engines:
_add(_eng, "ocr", {"capabilities": ["ocr"], "backend": "ocr"})
except Exception:
......
# CoderAI - OpenAI-compatible API server
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
# GPLv3 - see the project LICENSE.
"""Chandra OCR engine — Datalab's document VLM, driven over an OpenAI chat endpoint.
Chandra (``datalab-to/chandra-ocr-2``) comes from the same lab as Surya and is its
successor in spirit: one page image in, the whole page out as HTML. Unlike Surya it is
NOT a detector plus a recogniser — it is a single vision model, so (like olmOCR) we talk
to it over an OpenAI chat endpoint and nothing of its own runs here.
What makes it worth having alongside Surya-2 and olmOCR:
- **It returns layout boxes.** Its ``ocr_layout`` prompt asks for one ``<div>`` per layout
block carrying ``data-bbox`` (normalised 0-1000) and ``data-label`` from a fixed label
set. So it gives boxes AND labelled regions, which olmOCR cannot, and it gives them in
one pass, which Surya needs a detector for.
- **A different family.** Chandra 2 is a Qwen3-VL fine-tune with its own tokenizer, where
Surya-2 has its own 65k ``WordLevel`` vocabulary. That matters for the unreadable-glyph
problem (see ``codai.ocr.base.UNREADABLE``): running a page that loses its accents in
Surya through Chandra says whether the loss is the document's pixels or that one model.
- **Tables, maths, chemistry, forms.** Its prompt constrains the output to a known tag set
with ``<math>`` KaTeX, ``<chem>`` SMILES, colspan/rowspan tables and marked checkboxes.
Three ways to serve it (``ocr.chandra_serve``), the same three as olmOCR:
- ``model`` — through coderai's OWN model manager: register the checkpoint in models.json
as a vision model and put its id in ``ocr.chandra_model_id``. Gets VRAM accounting,
eviction and the cluster/RunPod dispatcher for free, and is the only mode that works on a
box with no GPU of its own — which is also the box whose whole job is to rent one.
- ``vllm`` — coderai's vLLM backend serves ``ocr.chandra_model`` on its own instance.
- ``server`` — attach to an OpenAI-compatible server already running it
(``ocr.chandra_server_url``).
LICENSE: the code is Apache-2.0 but the WEIGHTS are a modified OpenRAIL-M — free for
research, personal use and companies under $2M funding/revenue, and explicitly not for use
competing with Datalab's own API. That is a narrower grant than Surya's GPL, so this engine
is license-gated the same way: ``ocr.chandra_accept_license`` must be set deliberately.
"""
import base64
import io
import json
import threading
from html.parser import HTMLParser
from typing import List, Optional
from codai.ocr.base import (OcrEngine, OcrPage, OcrLine, OcrRegion, OcrError,
vlm_serve_limits)
DEFAULT_MODEL = "datalab-to/chandra-ocr-2"
VALID_MODES = ("model", "vllm", "server")
#: chandra/settings.py: MAX_OUTPUT_TOKENS. A dense page genuinely needs this much —
#: the model emits HTML, which is far more verbose than the text it carries.
DEFAULT_MAX_TOKENS = 12384
#: chandra/settings.py: BBOX_SCALE. data-bbox coordinates are normalised to this.
BBOX_SCALE = 1000
#: Labels the ``ocr_layout`` prompt tells the model to use. Kept so a label the model
#: invented can be told from one it was asked for, rather than both arriving as "a label".
LAYOUT_LABELS = (
"Caption", "Footnote", "Equation-Block", "List-Group", "Page-Header", "Page-Footer",
"Image", "Section-Header", "Table", "Text", "Complex-Block", "Code-Block", "Form",
"Table-Of-Contents", "Figure", "Chemical-Block", "Diagram", "Blank-Page",
)
#: Labels whose block is page furniture rather than content.
_FURNITURE = {"Page-Header", "Page-Footer"}
# ---------------------------------------------------------------------------
# the prompts
# ---------------------------------------------------------------------------
# Mirrored VERBATIM from chandra/prompts.py rather than importing chandra, which would
# drag torch, transformers, bs4 and markdownify into the main venv for two string
# constants. The model was trained on this exact wording — including the Python repr of
# the tag and attribute lists, which is why they are formatted the way they are.
ALLOWED_TAGS = [
"math", "br", "i", "b", "u", "del", "sup", "sub", "table", "tr", "td", "p", "th",
"div", "pre", "h1", "h2", "h3", "h4", "h5", "ul", "ol", "li", "input", "a", "span",
"img", "hr", "tbody", "small", "caption", "strong", "thead", "big", "code", "chem",
]
ALLOWED_ATTRIBUTES = [
"class", "colspan", "rowspan", "display", "checked", "type", "border", "value",
"style", "href", "alt", "align", "data-bbox", "data-label",
]
PROMPT_ENDING = f"""
Only use these tags {ALLOWED_TAGS}, and these attributes {ALLOWED_ATTRIBUTES}.
Guidelines:
* Inline math: Surround math with <math>...</math> tags. Math expressions should be rendered in KaTeX-compatible LaTeX. Use display for block math.
* Tables: Use colspan and rowspan attributes to match table structure.
* Formatting: Maintain consistent formatting with the image, including spacing, indentation, subscripts/superscripts, and special characters.
* Images: Include a description of any images in the alt attribute of an <img> tag. Do not fill out the src property. Describe in detail inside the div tag. Also convert charts to high fidelity data, and convert diagrams to mermaid.
* Forms: Mark checkboxes and radio buttons properly.
* Text: join lines together properly into paragraphs using <p>...</p> tags. Use <br> tags for line breaks within paragraphs, but only when absolutely necessary to maintain meaning.
* Chemistry: Use <chem>...</chem> tags for chemical formulas with reactive SMILES.
* Lists: Preserve indents and proper list markers.
* Use the simplest possible HTML structure that accurately represents the content of the block.
* Make sure the text is accurate and easy for a human to read and interpret. Reading order should be correct and natural.
""".strip()
OCR_LAYOUT_PROMPT = f"""
OCR this image to HTML, arranged as layout blocks. Each layout block should be a div with the data-bbox attribute representing the bounding box of the block in x0 y0 x1 y1 format. Bboxes are normalized 0-1000. The data-label attribute is the label for the block.
Use the following labels:
- Caption
- Footnote
- Equation-Block
- List-Group
- Page-Header
- Page-Footer
- Image
- Section-Header
- Table
- Text
- Complex-Block
- Code-Block
- Form
- Table-Of-Contents
- Figure
- Chemical-Block
- Diagram
- Bibliography
- Blank-Page
{PROMPT_ENDING}
""".strip()
OCR_PROMPT = f"""
OCR this image to HTML.
{PROMPT_ENDING}
""".strip()
PROMPT_MAPPING = {"ocr_layout": OCR_LAYOUT_PROMPT, "ocr": OCR_PROMPT}
# ---------------------------------------------------------------------------
# image prep
# ---------------------------------------------------------------------------
def scale_to_fit(img, max_size=(3072, 2048), min_size=(1792, 28), grid_size: int = 28):
"""Chandra's own input scaling (chandra/model/util.py), reimplemented.
Two things matter and neither is optional. The grid: a Qwen-family vision tower
tokenises in 28x28 patches, so dimensions are snapped to multiples of 28 — handing it
an off-grid image makes the server do this itself, differently. And the ceiling:
3072x2048 is 6,291,456 pixels, which is also Surya-2's cap, and it is reached by an A4
page at about 255 DPI (see ``OcrConfig.chandra_dpi``). Above that the page is scaled
DOWN before the model sees it, so rasterising higher only costs time.
The refinement loop is chandra's: after snapping to the grid the block count can creep
back over the ceiling, so blocks are shed one at a time, taking the one that distorts
the aspect ratio less.
"""
from PIL import Image
width, height = img.size
if width <= 0 or height <= 0:
return img
original_ar = width / height
current_pixels = width * height
max_pixels = max_size[0] * max_size[1]
min_pixels = min_size[0] * min_size[1]
scale = 1.0
if current_pixels > max_pixels:
scale = (max_pixels / current_pixels) ** 0.5
elif current_pixels < min_pixels:
scale = (min_pixels / current_pixels) ** 0.5
w_blocks = max(1, round((width * scale) / grid_size))
h_blocks = max(1, round((height * scale) / grid_size))
while (w_blocks * h_blocks * grid_size * grid_size) > max_pixels:
if w_blocks == 1 and h_blocks == 1:
break
if w_blocks == 1:
h_blocks -= 1
continue
if h_blocks == 1:
w_blocks -= 1
continue
ar_w_loss = abs(((w_blocks - 1) / h_blocks) - original_ar)
ar_h_loss = abs((w_blocks / (h_blocks - 1)) - original_ar)
if ar_w_loss < ar_h_loss:
w_blocks -= 1
else:
h_blocks -= 1
new_width, new_height = w_blocks * grid_size, h_blocks * grid_size
if (new_width, new_height) == (width, height):
return img
return img.resize((new_width, new_height), resample=Image.Resampling.LANCZOS)
def prepare_image(image) -> (str, tuple):
"""``(data URI, (w, h))`` for the image as the MODEL will see it.
The size is returned because the boxes come back normalised against it, and
de-normalising them against the pre-scaling size would put every box in the wrong
place on any page that was scaled.
"""
img = scale_to_fit(image.convert("RGB"))
buf = io.BytesIO()
img.save(buf, format="PNG")
uri = "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
return uri, img.size
# ---------------------------------------------------------------------------
# degenerate output
# ---------------------------------------------------------------------------
def detect_repeat_token(text: str, base_max_repeats: int = 4, window_size: int = 500,
cut_from_end: int = 0, scaling_factor: float = 3.0) -> bool:
"""Whether the reply ends in a repetition loop (chandra/model/util.py).
A VLM that loses its place on a page does not fail — it repeats the last thing it
saw until it runs out of tokens, and the result arrives looking like a successful
transcription. Chandra's own client treats this as a retryable error, and so do we:
shorter repeated sequences need more repetitions before they count, because a real
document legitimately repeats short strings (table cells, dotted leaders).
"""
if cut_from_end > 0:
text = text[:-cut_from_end]
for seq_len in range(1, window_size // 2 + 1):
candidate = text[-seq_len:]
if not candidate:
continue
max_repeats = int(base_max_repeats * (1 + scaling_factor / seq_len))
repeat_count = 0
pos = len(text) - seq_len
if pos < 0:
continue
while pos >= 0:
if text[pos:pos + seq_len] == candidate:
repeat_count += 1
pos -= seq_len
else:
break
if repeat_count > max_repeats:
return True
return False
# ---------------------------------------------------------------------------
# parsing the reply
# ---------------------------------------------------------------------------
class _Blocks(HTMLParser):
"""Split a reply into its TOP-LEVEL ``<div>`` blocks, keeping each one's inner HTML.
bs4 would be three lines of this, and is not a dependency of the main venv — adding
one for a tag split that stdlib does is not a trade worth making. Depth tracking is
the whole job: layout blocks nest (a table block contains divs), and only the outermost
carries the page-level ``data-bbox``.
"""
#: Tags the model emits unclosed, which must not push the depth counter.
VOID = {"br", "hr", "img", "input", "meta", "link"}
def __init__(self):
super().__init__(convert_charrefs=False)
self.blocks: List[dict] = []
self._depth = 0
self._attrs = {}
self._parts: List[str] = []
#: Content that arrived outside any div — a model that ignored the block
#: instruction still transcribed the page, and dropping it would be the
#: worst possible reading of "the output was not in the expected shape".
self.loose: List[str] = []
def handle_starttag(self, tag, attrs):
text = self.get_starttag_text() or ""
if tag == "div":
if self._depth == 0:
self._attrs = {k: v for k, v in attrs}
self._parts = []
self._depth = 1
return
self._depth += 1
self._emit(text)
def handle_startendtag(self, tag, attrs):
self._emit(self.get_starttag_text() or "")
def handle_endtag(self, tag):
if tag == "div" and self._depth > 0:
self._depth -= 1
if self._depth == 0:
self.blocks.append({"attrs": self._attrs,
"html": "".join(self._parts)})
self._attrs, self._parts = {}, []
return
if tag in self.VOID:
return
self._emit(f"</{tag}>")
def handle_data(self, data):
self._emit(data)
def handle_entityref(self, name):
self._emit(f"&{name};")
def handle_charref(self, name):
self._emit(f"&#{name};")
def _emit(self, text):
if self._depth > 0:
self._parts.append(text)
elif text.strip():
self.loose.append(text)
def close(self):
super().close()
# An unclosed final div still holds a block's worth of page.
if self._depth > 0 and self._parts:
self.blocks.append({"attrs": self._attrs, "html": "".join(self._parts)})
self._attrs, self._parts, self._depth = {}, [], 0
def html_to_text(html: str) -> str:
"""Tag-stripped text of a block, with structure turned into newlines.
``<math>``/``<chem>`` content is LaTeX and SMILES and survives the strip as-is, which
is what a text consumer wants: losing the formula entirely would be worse than having
it unrendered.
"""
import html as _htmlmod
import re
if not html:
return ""
s = re.sub(r"(?i)<br\s*/?>", "\n", html)
# Cells get a SPACE, not a newline: </tr> already ends the row, and without this
# a table flattens to "VoceImporto" — two columns welded into one word, which is
# worse than useless to anything indexing or searching the text.
s = re.sub(r"(?i)</(td|th)>", " ", s)
s = re.sub(r"(?i)</(p|div|li|tr|h[1-6]|pre|caption)>", "\n", s)
s = re.sub(r"<[^>]+>", "", s)
s = _htmlmod.unescape(s)
return "\n".join(ln.strip() for ln in s.splitlines() if ln.strip()).strip()
def parse_bbox(value, width: int, height: int, scale: int = BBOX_SCALE
) -> Optional[List[float]]:
"""``"x0 y0 x1 y1"`` normalised to ``scale`` → pixel box, or None.
None rather than a full-page box when it cannot be read: a wrong box pointing at the
whole page looks like a real answer, and anything drawing on it or cropping by it would
be silently wrong. A missing box is at least visibly missing.
"""
try:
parts = str(value or "").replace(",", " ").split()
nums = [float(p) for p in parts]
except Exception:
return None
if len(nums) != 4 or scale <= 0:
return None
sx, sy = width / float(scale), height / float(scale)
x0, y0, x1, y1 = nums
box = [max(0.0, x0 * sx), max(0.0, y0 * sy),
min(x1 * sx, float(width)), min(y1 * sy, float(height))]
if box[2] <= box[0] or box[3] <= box[1]:
return None
return box
def parse_page(content: str, width: int, height: int, index: int = 0,
include_furniture: bool = True) -> OcrPage:
"""Chandra's reply → an :class:`OcrPage`.
One top-level div is one layout block, and becomes all three of the things coderai's
result types carry: a line (its text), a region (its label and box) and — when it is a
table — a table entry keeping the HTML, because a table flattened to text has lost the
only thing that made it a table.
"""
page = OcrPage(index=index, width=int(width), height=int(height))
parser = _Blocks()
parser.feed(content or "")
parser.close()
texts, labels = [], {}
for blk in parser.blocks:
attrs = blk["attrs"]
label = (attrs.get("data-label") or "").strip() or "block"
if label == "Blank-Page":
continue
if not include_furniture and label in _FURNITURE:
continue
box = parse_bbox(attrs.get("data-bbox"), width, height)
text = html_to_text(blk["html"])
labels[label] = labels.get(label, 0) + 1
if box is not None:
page.regions.append(OcrRegion(label=label, bbox=box, conf=0.0))
if not text:
continue
# conf 0.0 throughout: the model reports none, and inventing one would be
# read as a measurement. Callers that gate on confidence must not be fed a
# number nobody computed.
page.lines.append(OcrLine(text=text, bbox=box or [0.0, 0.0, 0.0, 0.0], conf=0.0))
texts.append(text)
if label == "Table":
page.tables.append({"bbox": box, "html": blk["html"]})
# A reply with no blocks at all is still a transcription — keep it.
if not texts and parser.loose:
loose = html_to_text("".join(parser.loose))
if loose:
texts.append(loose)
page.lines.append(OcrLine(text=loose, bbox=[0.0, 0.0, 0.0, 0.0], conf=0.0))
if not texts and not parser.blocks:
loose = html_to_text(content or "")
if loose:
texts.append(loose)
page.lines.append(OcrLine(text=loose, bbox=[0.0, 0.0, 0.0, 0.0], conf=0.0))
page.text = "\n\n".join(texts)
if labels:
page.meta["labels"] = labels
return page
# ---------------------------------------------------------------------------
# engine
# ---------------------------------------------------------------------------
_loop = None
_loop_lock = threading.Lock()
def _own_loop():
"""A module-owned event loop, for the in-process model path when no host loop was
handed to us (direct and unit-test calls; the pool passes the app's own)."""
import asyncio
global _loop
with _loop_lock:
if _loop is not None and not _loop.is_closed():
return _loop
_loop = asyncio.new_event_loop()
threading.Thread(target=_loop.run_forever, name="chandra-loop",
daemon=True).start()
return _loop
class ChandraEngine(OcrEngine):
name = "chandra"
def __init__(self, cfg):
super().__init__(cfg)
self._base = "" # OpenAI base URL for the http modes
self._host_loop = None # set by the pool: the app's event loop
# -- config ------------------------------------------------------------
def _mode(self) -> str:
mode = (getattr(self.cfg, "chandra_serve", "model") or "model").strip().lower()
if mode not in VALID_MODES:
raise OcrError(
f"invalid ocr.chandra_serve '{mode}' (use {'/'.join(VALID_MODES)})",
status=400)
return mode
def _model_name(self) -> str:
return (getattr(self.cfg, "chandra_model", "") or DEFAULT_MODEL).strip()
def _prompt(self) -> str:
"""``ocr_layout`` unless the operator turned layout off.
Layout is the default because it is the reason to pick this engine over olmOCR:
without it the reply is one unlocated blob and the boxes coderai's result type
carries are all empty.
"""
want_layout = bool(getattr(self.cfg, "chandra_layout", True))
return PROMPT_MAPPING["ocr_layout" if want_layout else "ocr"]
# -- lifecycle ---------------------------------------------------------
def load(self) -> None:
if not bool(getattr(self.cfg, "chandra_accept_license", False)):
raise OcrError(
"Chandra is license-gated: its WEIGHTS are a modified OpenRAIL-M "
"(free for research, personal use and companies under $2M "
"funding/revenue, and not for use competing with Datalab's own API). "
"Set ocr.chandra_accept_license = true to use it.",
status=400)
mode = self._mode()
self._base = ""
if mode == "model":
if not (getattr(self.cfg, "chandra_model_id", "") or "").strip():
raise OcrError(
"Chandra 'model' mode needs ocr.chandra_model_id — the id of a "
"VISION model in models.json holding a Chandra checkpoint (it must "
"be served over HTTP: backend \"runpod\", a service_url, or the "
"local vLLM backend). Set it, or switch ocr.chandra_serve to "
"vllm/server.", status=400)
return
if mode == "server":
url = (getattr(self.cfg, "chandra_server_url", "") or "").strip()
if not url:
raise OcrError(
"Chandra 'server' mode needs ocr.chandra_server_url (an "
"OpenAI-compatible endpoint already serving Chandra).", status=400)
self._base = url.rstrip("/")
if not self._base.endswith("/v1"):
self._base += "/v1"
return
# vllm: boot (or reuse) coderai's vLLM backend on the Chandra checkpoint.
from codai.api import vllm_worker
from codai.models.manager import get_active_vllm_config
vcfg = get_active_vllm_config()
if vcfg is None:
raise OcrError("Chandra vllm mode needs the vLLM backend configured",
status=400)
model = self._model_name()
base = vllm_worker.ensure_service(vcfg, model_path=model, served_name=model,
gpu_memory_utilization=self._vlm_gmu(),
**vlm_serve_limits(self.cfg))
self._base = base.rstrip("/") + "/v1"
def cleanup(self) -> None:
# Nothing of ours holds VRAM: 'model' mode is the model manager's, 'vllm' mode is
# the shared service's (stopped by the manager's releaser), 'server' mode is
# another process entirely.
self._loaded = False
def vram_gb(self) -> float:
# Per INSTANCE this is one in-flight page against a shared server, so nil. The
# real cost is declared once through prelaunch_vram_gb().
return 0.0
def prelaunch_vram_gb(self) -> float:
"""vLLM claims gpu_memory_utilization × the whole card before it will serve and
refuses to start when that much is not free, so it has to be freed BEFORE the
load, not after it (see Surya's crash loop)."""
return self._vllm_need() if self._mode() == "vllm" else 0.0
def _vlm_gmu(self):
"""The OCR-side share of the card for this engine's vLLM (0/None = the backend's)."""
try:
v = float(getattr(self.cfg, "vlm_gpu_memory_utilization", 0.0) or 0.0)
except Exception:
v = 0.0
return v or None
def _vllm_need(self) -> float:
try:
from codai.api.vllm_worker import prelaunch_free_gb
from codai.models.manager import get_active_vllm_config
vcfg = get_active_vllm_config()
return prelaunch_free_gb(vcfg, self._vlm_gmu()) if vcfg is not None else 0.0
except Exception:
return 0.0
# -- inference ---------------------------------------------------------
def recognize_image(self, image) -> OcrPage:
src_w, src_h = image.size
data_uri, (w, h) = prepare_image(image)
content, meta = self._ask_with_retries(data_uri)
page = parse_page(
content, w, h,
include_furniture=bool(getattr(self.cfg, "chandra_include_furniture", True)))
# The boxes are the model's, normalised against the image the model saw; the page
# dimensions reported are the same ones, so a client scaling boxes back onto its
# own render has a consistent pair. The source size is reported too, since it is
# the one the caller rasterised and the only way to see that scaling happened.
if (src_w, src_h) != (w, h):
meta["source_size"] = [int(src_w), int(src_h)]
page.meta.update(meta)
return page
def _ask_with_retries(self, data_uri) -> (str, dict):
"""The page, re-asked while the reply is degenerate.
A repetition loop is not an error the server reports — it answers 200 with a
plausible-looking page. Chandra's own client raises the temperature on each retry
(a greedy decode that fell into a loop will fall into the same one again) and so do
we, capped the same way.
"""
base_temp = float(getattr(self.cfg, "chandra_temperature", 0.0) or 0.0)
top_p = float(getattr(self.cfg, "chandra_top_p", 0.1) or 0.1)
max_retries = max(0, int(getattr(self.cfg, "chandra_max_retries", 2) or 0))
meta = {}
content = ""
for attempt in range(max_retries + 1):
if attempt == 0:
temp, p = base_temp, top_p
else:
temp, p = min(base_temp + 0.2 * attempt, 0.8), 0.95
content = self._ask(data_uri, temp, p)
if not detect_repeat_token(content):
if attempt:
meta["retries"] = attempt
return content, meta
# Out of retries, and the last reply still loops. Returned anyway, flagged: a
# looping transcription usually has a correct beginning, and the flag is what lets
# a caller decide rather than be handed a silent mess.
meta["retries"] = max_retries
meta["repeat_detected"] = True
return content, meta
def _ask(self, data_uri, temperature: float, top_p: float) -> str:
"""One page → the model's raw reply text."""
messages = [{
"role": "user",
"content": [
# Image FIRST, then the instruction: that is the order chandra's own
# client builds (chandra/model/vllm.py), and for a VLM the order the
# model was trained on is part of the prompt.
{"type": "image_url", "image_url": {"url": data_uri}},
{"type": "text", "text": self._prompt()},
],
}]
max_tokens = int(getattr(self.cfg, "chandra_max_tokens", DEFAULT_MAX_TOKENS)
or DEFAULT_MAX_TOKENS)
if self._mode() == "model":
return self._ask_model(messages, max_tokens, temperature, top_p)
return self._ask_http(messages, max_tokens, temperature, top_p)
def _ask_model(self, messages, max_tokens, temperature, top_p) -> str:
"""Model mode: ask the dispatcher which server should take this page.
The in-process path is right for a model this machine serves and wrong for one
served elsewhere — it cannot see which cluster node is least busy, and it bypasses
the front, where burst-to-RunPod lives. So the choice is made per page.
"""
model_id = (getattr(self.cfg, "chandra_model_id", "") or "").strip()
try:
from codai.models import placement
lease = placement.choose(
model_id, spill_after=placement.spill_threshold(model_id))
except Exception:
return self._ask_local(messages, max_tokens, temperature, top_p)
try:
if lease.kind == "local":
return self._ask_local(messages, max_tokens, temperature, top_p)
return self._ask_http(messages, max_tokens, temperature, top_p,
base=lease.base, api_key=lease.api_key,
model_name=model_id)
finally:
lease.release()
def _ask_local(self, messages, max_tokens, temperature, top_p) -> str:
"""Through coderai's own in-process chat handler (model mode)."""
import asyncio
from codai.api.text import chat_completions
from codai.pydantic.textrequest import ChatCompletionRequest
model_id = (getattr(self.cfg, "chandra_model_id", "") or "").strip()
req = ChatCompletionRequest(
model=model_id, messages=messages, stream=False,
temperature=temperature, top_p=top_p, max_tokens=max_tokens,
)
loop = self._host_loop
if loop is None or loop.is_closed():
loop = _own_loop()
fut = asyncio.run_coroutine_threadsafe(chat_completions(req, None), loop)
try:
result = fut.result(
timeout=float(getattr(self.cfg, "chandra_timeout", 300.0) or 300.0))
except Exception as e:
raise OcrError(f"Chandra model '{model_id}' call failed: {e}", status=500)
content = _content_of(result)
if not content.strip():
raise OcrError(f"Chandra model '{model_id}' returned no text", status=500)
return content
def _ask_http(self, messages, max_tokens, temperature, top_p, base: str = "",
api_key: str = "", model_name: str = "") -> str:
"""Against an OpenAI-compatible server (vllm/server modes).
``base``/``api_key``/``model_name`` override the configured server for one page,
which is how model mode reaches the upstream the dispatcher chose.
"""
# pod_http, not requests: in model mode ``base`` can be a RunPod pod on the
# direct-TCP path — https to a bare IP serving a certificate signed by THIS
# install's own CA, which no public CA knows and which was issued before the
# address was. Plain requests fails it twice over with CERTIFICATE_VERIFY_FAILED.
# pod_http is a drop-in that hands every other URL straight to requests.
from codai.api import pod_http
payload = {
"model": model_name or self._model_name(),
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"top_p": top_p,
"stream": False,
}
headers = {"Content-Type": "application/json"}
key = api_key or (getattr(self.cfg, "chandra_api_key", "") or "").strip()
if key:
headers["Authorization"] = f"Bearer {key}"
url = (base or self._base).rstrip("/") + "/chat/completions"
try:
r = pod_http.post(
url, json=payload, headers=headers,
timeout=float(getattr(self.cfg, "chandra_timeout", 300.0) or 300.0))
except Exception as e:
raise OcrError(f"Chandra server {url} unreachable: {e}", status=503)
if r.status_code >= 400:
raise OcrError(f"Chandra server {url} returned HTTP {r.status_code}: "
f"{r.text[:300]}", status=502)
try:
content = _content_of(r.json())
except Exception as e:
raise OcrError(f"Chandra server returned unparseable JSON: {e}", status=502)
if not content.strip():
raise OcrError("Chandra server returned no text", status=502)
return content
def _content_of(result) -> str:
"""Assistant message content out of a chat-completions result, whatever shape it
arrives in (dict, pydantic model, JSONResponse)."""
r = None
if isinstance(result, dict):
r = result
elif hasattr(result, "model_dump"):
try:
r = result.model_dump()
except Exception:
r = None
if r is None and hasattr(result, "body"):
try:
r = json.loads(result.body)
except Exception:
r = None
if not isinstance(r, dict):
return ""
choices = r.get("choices") or []
if not choices:
return ""
first = choices[0]
if not isinstance(first, dict):
return ""
msg = first.get("message")
if isinstance(msg, dict):
c = msg.get("content")
if isinstance(c, list): # multipart reply — join its text parts
return "".join(p.get("text", "") for p in c if isinstance(p, dict))
return c or ""
return first.get("text") or ""
......@@ -58,11 +58,17 @@ def _olmocr_factory(cfg):
return OlmOcrEngine(cfg)
def _chandra_factory(cfg):
from codai.ocr.chandra import ChandraEngine
return ChandraEngine(cfg)
_ENGINE_FACTORIES = {
"paddle": _paddle_factory,
"doctr": _doctr_factory,
"surya": _surya_factory,
"olmocr": _olmocr_factory,
"chandra": _chandra_factory,
}
......@@ -173,6 +179,11 @@ def _engine_enabled(cfg, name: str) -> bool:
return bool(cfg.surya_enabled and cfg.surya_accept_license)
if name == "olmocr":
return bool(getattr(cfg, "olmocr_enabled", False))
if name == "chandra":
# License-gated like surya, and on narrower terms: the weights are a
# modified OpenRAIL-M, not GPL (see OcrConfig.chandra_accept_license).
return bool(getattr(cfg, "chandra_enabled", False)
and getattr(cfg, "chandra_accept_license", False))
return False
......@@ -368,9 +379,9 @@ class _Pool:
eng: OcrEngine = await self._q.get()
self._enter_page()
try:
# Engines that call back into coderai's async API (olmOCR 'model' mode) run
# in a worker thread and need a live loop to hand the coroutine to; give them
# the app's own rather than letting them spin up a second one.
# Engines that call back into coderai's async API (olmOCR/Chandra 'model'
# mode) run in a worker thread and need a live loop to hand the coroutine to;
# give them the app's own rather than letting them spin up a second one.
try:
eng._host_loop = asyncio.get_running_loop()
except Exception:
......@@ -441,6 +452,7 @@ class OcrManager:
# reclaim it; the next OCR request re-boots it via ensure_service.
freed += self._stop_surya_vllm()
freed += self._stop_olmocr_vllm()
freed += self._stop_chandra_vllm()
if freed:
print(f"[ocr] released ~{freed:.1f} GB (pools + Surya vLLM torn down for VRAM eviction)")
return freed
......@@ -490,6 +502,28 @@ class OcrManager:
print(f"[ocr] olmOCR vLLM stop skipped: {e}")
return 0.0
def _stop_chandra_vllm(self) -> float:
"""Stop the managed Chandra vLLM instance (chandra_serve = "vllm"), for the same
reason Surya's and olmOCR's need stopping: its VRAM belongs to the subprocess,
not to a pool, so tearing the pool down does not reclaim it."""
cfg = self._cfg # unconditional, for the reason in _stop_surya_vllm
if cfg is None:
return 0.0
try:
from codai.api import vllm_worker
from codai.models.manager import get_active_vllm_config
from codai.ocr.chandra import DEFAULT_MODEL
vcfg = get_active_vllm_config()
if vcfg is None:
return 0.0
model = (getattr(cfg, "chandra_model", "") or DEFAULT_MODEL).strip()
return vllm_worker.stop_service_for(
vcfg, model_path=model, served_name=model,
gpu_memory_utilization=float(getattr(cfg, "vlm_gpu_memory_utilization", 0.0) or 0.0) or None)
except Exception as e:
print(f"[ocr] Chandra vLLM stop skipped: {e}")
return 0.0
@staticmethod
def _pool_params(cfg) -> tuple:
return (
......@@ -509,6 +543,12 @@ class OcrManager:
getattr(cfg, "olmocr_serve", "model"), getattr(cfg, "olmocr_model", ""),
getattr(cfg, "olmocr_model_id", ""), getattr(cfg, "olmocr_server_url", ""),
getattr(cfg, "olmocr_longest_side", 1288),
getattr(cfg, "chandra_enabled", False),
getattr(cfg, "chandra_accept_license", False),
getattr(cfg, "chandra_instances", 1),
getattr(cfg, "chandra_serve", "model"), getattr(cfg, "chandra_model", ""),
getattr(cfg, "chandra_model_id", ""), getattr(cfg, "chandra_server_url", ""),
getattr(cfg, "chandra_layout", True),
getattr(cfg, "vlm_gpu_memory_utilization", 0.0),
)
......@@ -530,14 +570,24 @@ class OcrManager:
"doctr": {"boxes": True, "regions": True, "tables": False, "meta": False},
"surya": {"boxes": True, "regions": True, "tables": True, "meta": False},
"olmocr": {"boxes": False, "regions": False, "tables": True, "meta": True},
# Chandra is a VLM like olmOCR but it is ASKED for boxes: its ocr_layout
# prompt returns one div per block with data-bbox and data-label, so it is
# the only engine here that gives boxes, labelled regions, tables AND page
# metadata in a single pass. (With ocr.chandra_layout off it has none of
# the three, which is why the row is read from the setting.)
"chandra": {"boxes": bool(getattr(cfg, "chandra_layout", True)),
"regions": bool(getattr(cfg, "chandra_layout", True)),
"tables": bool(getattr(cfg, "chandra_layout", True)),
"meta": True},
}
# A pipeline drives a model somebody else serves; an engine owns its own
# weights and runs in process. The distinction decides whether "where does
# it run" is even a question, so it is worth saying out loud.
kinds = {"paddle": "engine", "doctr": "engine",
"surya": "pipeline", "olmocr": "pipeline"}
"surya": "pipeline", "olmocr": "pipeline", "chandra": "pipeline"}
models = {"surya": ("surya_model_id", "surya_model"),
"olmocr": ("olmocr_model_id", "olmocr_model")}
"olmocr": ("olmocr_model_id", "olmocr_model"),
"chandra": ("chandra_model_id", "chandra_model")}
data = []
for name in sorted(_ENGINE_FACTORIES):
......@@ -558,6 +608,13 @@ class OcrManager:
if name == "surya" and cfg.surya_enabled and not cfg.surya_accept_license:
row["reason"] = ("Surya's licence has not been accepted "
"(ocr.surya_accept_license)")
elif (name == "chandra" and getattr(cfg, "chandra_enabled", False)
and not getattr(cfg, "chandra_accept_license", False)):
row["reason"] = ("Chandra's licence has not been accepted "
"(ocr.chandra_accept_license) — its weights are a "
"modified OpenRAIL-M, free for research, personal "
"use and companies under $2M, and not for use "
"competing with Datalab's API")
else:
row["reason"] = f"not enabled (ocr.{name}_enabled)"
# The ids that ALSO resolve to this engine, so a client can name the
......@@ -593,7 +650,8 @@ class OcrManager:
"""
cfg = self._require_cfg()
for engine, fields in (("surya", ("surya_model_id", "surya_model")),
("olmocr", ("olmocr_model_id", "olmocr_model"))):
("olmocr", ("olmocr_model_id", "olmocr_model")),
("chandra", ("chandra_model_id", "chandra_model"))):
for field in fields:
got = str(getattr(cfg, field, "") or "").strip().lower()
if got and got == wanted:
......@@ -705,7 +763,7 @@ class OcrManager:
if raw in ("off", "none", "no", "false"):
return []
if raw in ("auto", "", "on", "true"):
names = ["paddle", "doctr", "olmocr", "surya"]
names = ["paddle", "doctr", "olmocr", "chandra", "surya"]
else:
names = [n.strip() for n in raw.replace(",", " ").split() if n.strip()]
return [n for n in names
......
......@@ -2,7 +2,7 @@
CoderAI OCRs documents with **purpose-built OCR engines** (detection + recognition) —
faithful transcription with per-line bounding boxes and layout, GPU batching, no
hallucinated text — and, for the pages those read badly, one **document VLM**. Four
hallucinated text — and, for the pages those read badly, two **document VLMs**. Five
engines are integrated and selectable per request:
| engine | id | license | how it runs |
......@@ -11,6 +11,7 @@ engines are integrated and selectable per request:
| [PaddleOCR](https://github.com/PaddlePaddle/PaddleOCR) + PP-Structure | `paddle` | Apache-2.0 | **isolated venv subprocess** (layout + tables) |
| [Surya](https://github.com/VikParuchuri/surya) | `surya` | GPL (compatible with coderai's GPLv3) | **isolated venv subprocess**; best layout/reading-order; opt-in |
| [olmOCR-2](https://huggingface.co/allenai/olmOCR-2-7B-1025) | `olmocr` | Apache-2.0 | **a document VLM**, served by a coderai model, coderai's vLLM, or any OpenAI endpoint. **No bounding boxes.** |
| [Chandra](https://huggingface.co/datalab-to/chandra-ocr-2) | `chandra` | code Apache-2.0, **weights modified OpenRAIL-M** (research/personal/under-$2M, no competitive use) | **a document VLM that returns layout** — boxes, labelled regions and tables in one pass. Served like olmOCR; opt-in. |
Per document you get three outputs: **plain text**, **structured JSON fields**, and
**stamp/signature flags**.
......@@ -18,9 +19,14 @@ Per document you get three outputs: **plain text**, **structured JSON fields**,
**Which one?** `paddle`/`doctr`/`surya` give you boxes, layout regions and tables, which
is what you want when something downstream has to point at a place on the page (redaction,
stamp detection, a table's cells). `olmocr` gives you the best *reading* of a hard page —
old scans, maths, multi-column, dense tables — and nothing positional. Stamp/signature
detection in `layout` mode has nothing to work with under `olmocr`; `detector` mode (YOLO)
still works, because it looks at the image, not the engine's output.
old scans, maths, multi-column, dense tables — and nothing positional. `chandra` is the
one that does both: it is a VLM, so it reads a hard page like olmOCR, but it is *asked*
for its layout, so it answers with one block per region carrying a box and a label. It is
also the only engine here whose licence has a revenue ceiling, which is the reason it is
not simply the default. Stamp/signature detection in `layout` mode has nothing to work
with under `olmocr`; it works under `chandra` (the regions are real) and under the three
detector-based engines, and `detector` mode (YOLO) works everywhere because it looks at
the image, not the engine's output.
## Why isolated venvs for Paddle & Surya
......@@ -163,9 +169,125 @@ do not carry, so a pod asked for `olmocr` serves the request with docTR and says
log. Point `olmocr_serve: "server"` at something the pod can reach if you want the real
thing out there.
## Chandra (`chandra`) — the document VLM that *also* gives you boxes
[Chandra](https://huggingface.co/datalab-to/chandra-ocr-2) is Datalab's document VLM — the
same lab as Surya, and a Qwen3-VL fine-tune. Like olmOCR it reads a whole page in one
call; unlike olmOCR it is **asked for its layout**, and that is the point of having it.
Its `ocr_layout` prompt tells the model to answer as one `<div>` per layout block carrying
`data-bbox="x0 y0 x1 y1"` (normalised 0–1000) and `data-label` from a fixed set — `Text`,
`Section-Header`, `Table`, `Figure`, `Form`, `Caption`, `Footnote`, `Equation-Block`,
`Code-Block`, `Chemical-Block`, `Page-Header`, `Page-Footer`, `Table-Of-Contents`,
`Diagram`, `List-Group`, `Complex-Block`, `Blank-Page`. coderai turns each block into all
three things a page carries: a **line** (its text), a **region** (its label and box) and,
for a table, a **table entry keeping the HTML** — because a table flattened to text has
lost the one thing that made it a table. Maths comes back as `<math>` KaTeX and chemistry
as `<chem>` SMILES, and both survive into the text as their source: unrendered LaTeX beats
a formula silently deleted.
The prompts are mirrored **verbatim** from `chandra/prompts.py`, down to the Python repr of
the allowed-tag list, because that wording is what the model was trained on. Nothing of
chandra's own package is installed: the engine is PIL plus an HTTP call, and the reply is
split with stdlib `html.parser` rather than pulling bs4 and markdownify into the main venv
for a tag split.
### Three reasons to reach for it
1. **You need boxes from a VLM.** `olmocr` has none at all; `chandra` has them for every
block in one pass, with no separate detector.
2. **It is a different model family from Surya-2.** Surya-2 has its own 65k `WordLevel`
vocabulary; Chandra has Qwen3-VL's tokenizer. So it is the useful *second opinion* on a
page Surya reads badly — including a page whose accents come back as the
unreadable-glyph marker (see below). Running the same page through both separates "this
document's pixels are too few" from "this one model cannot read this glyph".
3. **90+ languages**, with no language input to set — `lang=` is accepted and does not
steer it, same as surya-2 and olmOCR (`language_aware: false` in
`/v1/ocr/engines`).
### Licence — read this before enabling it
The code is Apache-2.0 but the **weights are a modified OpenRAIL-M**: free for research,
personal use and companies under **$2M** funding/revenue, and **not for use competing with
Datalab's own API**. Commercial self-hosting beyond that needs a licence from Datalab.
That is a **narrower grant than Surya's GPL**, so the engine is gated the same way and the
gate behaves differently in one place that matters: asking a rented pod for `surya` by name
*is* the GPL acceptance (the deployment's own decision travelling to the pod), but
`chandra_accept_license` is only ever **inherited from a decision already recorded in this
install's config**. A pod cannot accept a licence on your behalf.
```yaml
ocr:
chandra_enabled: true
chandra_accept_license: true # nothing loads until this is set deliberately
chandra_serve: model
chandra_model_id: chandra # a vision model in models.json
```
### Serving it
The same three modes as olmOCR, and for the same reasons:
| mode | what it means | needs |
|---|---|---|
| `model` (default) | the checkpoint is a **vision model in your models.json** and Chandra goes through coderai's own model manager — VRAM accounting, eviction, the cluster/RunPod dispatcher. The only mode that works on a box with **no GPU of its own**, which is also the box whose whole job is renting one. | `chandra_model_id` |
| `vllm` | coderai's vLLM backend serves `chandra_model` on its own instance (continuous batching). CUDA only. | the vLLM backend configured |
| `server` | attach to an OpenAI-compatible server already running it. | `chandra_server_url` (+ `chandra_api_key` if it wants one) |
In `model` mode the request goes through `pod_http`, so a RunPod pod reached over direct
TCP — https to a bare IP, certificate signed by **this install's own CA** — is verified
against that CA. Plain `requests` fails it twice over with `CERTIFICATE_VERIFY_FAILED`,
which is exactly how olmOCR came to be unreachable before 0.3.22.
### DPI: the ceiling is Surya's, to the pixel
`chandra/model/util.py`'s `scale_to_fit` caps the input at **3072×2048 = 6,291,456
pixels** — the same numbers as surya-2 — and snaps dimensions to a **28-pixel grid**,
because a Qwen-family vision tower tokenises in 28×28 patches. An A4 page reaches that
ceiling at about **255 DPI**, so `chandra_dpi` defaults to **250** for precisely the reason
`surya_dpi` does, and rasterising higher costs time and memory for no extra pixels at the
model. coderai does the scaling itself (same algorithm, same grid) and reports the size the
model actually saw as the page's `width`/`height`, with the size you rasterised in
`meta.source_size` when they differ — the boxes are normalised against what the model saw,
and de-normalising them against the pre-scaling size would put every box on a scaled page
in the wrong place.
### Repetition loops
A VLM that loses its place on a page does not fail. It repeats the last thing it saw until
it runs out of tokens, and the server answers **200** with a page that looks plausible.
Chandra's own client treats that as retryable and so does coderai: `chandra_max_retries`
(default 2) re-asks when the reply ends in a repetition, raising the temperature each time,
because a greedy decode falls into the same loop twice. If it still loops after the last
retry the page is returned anyway with `repeat_detected: true` in its `meta` — a looping
transcription usually has a correct beginning, and the flag is what lets you decide instead
of being handed a silent mess. `meta.retries` records how many re-asks it took.
### Fields
`chandra_enabled`, `chandra_accept_license`, `chandra_serve`, `chandra_model_id`,
`chandra_model` (default `datalab-to/chandra-ocr-2`), `chandra_server_url`,
`chandra_api_key`, `chandra_instances` (pages in flight), `chandra_dpi`, `chandra_layout`
(off asks for plain HTML — faster, and then there are no boxes or regions at all),
`chandra_include_furniture` (keep `Page-Header`/`Page-Footer` in the text; on, because a
judicial document's header carries the court and the case number), `chandra_max_tokens`
(12384 — HTML runs far longer than the text it carries), `chandra_temperature` (0, greedy),
`chandra_top_p` (0.1), `chandra_max_retries`, `chandra_timeout`.
```bash
curl -F file=@sentenza.pdf -F engine=chandra http://localhost:8776/v1/ocr
```
**A pod cannot serve it either**, for the same reason as Surya-2 and olmOCR: the RunPod OCR
images carry no model server, and baking one in would put a second torch in a ~12 GB image.
A pod asked for `chandra` serves the request with docTR and says so in the log. Point
`chandra_serve: "server"` at something the pod can reach — including a vLLM pod — if you
want the real thing out there.
## When an engine's server will not start
The VLM engines (`surya` in `vllm` mode, `olmocr` in `vllm` mode) boot a vLLM instance
The VLM engines in `vllm` mode (`surya`, `olmocr`, `chandra`) boot a vLLM instance
that claims `gpu_memory_utilization` × the **whole card** before it will serve, and
refuses to start when that much is not free. Three rules follow, all enforced by the pool:
......@@ -266,7 +388,8 @@ back.
## Enable & configure
Settings → **OCR** card, or `config.json` `"ocr"`. Key fields: `enabled`,
`default_engine`, `dpi`, `surya_dpi`, `max_concurrency`, `lang`, `paddle_auto_lang`, `build_retry_cooldown_s`,
`default_engine`, `dpi`, `surya_dpi`, `chandra_dpi`, `max_concurrency`, `lang`,
`paddle_auto_lang`, `build_retry_cooldown_s`,
`vlm_gpu_memory_utilization`, `vlm_max_num_batched_tokens`, `vlm_max_num_seqs`,
`vlm_max_model_len`, `fallback_engines`, `evict_drain_timeout_s`;
per-engine `*_enabled` /
......@@ -335,6 +458,7 @@ which it was:
| `doctr` | accepted, no effect | recognition is Latin-script-wide |
| `surya` | accepted, no effect | VLM; its whole prompt is `"OCR this block image to HTML."` |
| `olmocr` | accepted, no effect | VLM; fixed prompt |
| `chandra` | accepted, no effect | VLM; fixed prompt (90+ languages, no language input) |
So every response carries both the language applied and whether the engine that
served could act on it — silently ignoring `lang` would leave a caller believing it
......@@ -453,6 +577,14 @@ the document's own glyph quality and the serving stack (coderai's pods run vLLM
them apart is to OCR **perfect synthetic Italian** through the same path — clean
output indicts the document, U+FFFD on crisp vector glyphs indicts the stack.
There is now a second, cheaper discriminator: **run the page through `chandra`**.
It is a different model family with a different tokenizer (Qwen3-VL, against
surya-2's own 65k `WordLevel` vocabulary), reached over the same transport, at the
same pixel ceiling, from the same rasterised page. Accents intact under chandra
and lost under surya is as close to a controlled experiment as this gets, and it
points at the model rather than the document. Both losing them points back at the
pixels — and then `pdfimages -list` is the next thing to read.
`dpi` only affects **PDF** input. An uploaded image is used at the resolution it
arrives, so a low-resolution scan cannot be rescued by this setting.
......
"""Chandra: a document VLM that is ASKED for its layout.
Chandra comes from the same lab as Surya and is driven the same way olmOCR is — one
page image over an OpenAI chat endpoint — but its ``ocr_layout`` prompt asks for one
``<div>`` per block carrying ``data-bbox`` and ``data-label``. So it is the only engine
here that returns text, boxes, labelled regions, tables AND page metadata in one pass.
What these tests are actually guarding:
- **The prompt is a contract.** It is mirrored verbatim from chandra/prompts.py, down to
the Python repr of the tag list, because the model was trained on that wording.
- **The parse must not lose page.** A reply in an unexpected shape (no blocks, an
unclosed div, a bbox that will not parse) is still a transcription of somebody's
document, and the worst possible reading of "unexpected shape" is to drop it.
- **A repetition loop answers 200.** A VLM that loses its place repeats itself until the
token budget runs out, and the result looks like a successful page. It has to be caught
and flagged rather than handed on.
- **The licence is narrower than Surya's.** OpenRAIL-M with a $2M ceiling and no
competitive use, so nothing loads un-accepted, and a rented pod can never accept on the
operator's behalf.
"""
import pathlib
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
import pytest # noqa: E402
from codai.config import OcrConfig # noqa: E402
from codai.ocr import chandra as ch # noqa: E402
from codai.ocr import manager as om # noqa: E402
from codai.ocr.base import OcrError, count_unreadable # noqa: E402
def _cfg(**kw):
cfg = OcrConfig()
cfg.enabled = True
cfg.chandra_enabled = True
cfg.chandra_accept_license = True
for k, v in kw.items():
setattr(cfg, k, v)
return cfg
# A page of the kind this is actually for: an Italian judgment, with the accents
# that started this whole investigation, a table, inline maths and page furniture.
REPLY = (
'<div data-bbox="100 50 900 90" data-label="Page-Header">'
'<p>TRIBUNALE DI MILANO &mdash; Sez. II</p></div>\n'
'<div data-bbox="100 120 900 200" data-label="Section-Header">'
'<h2>Motivi della decisione</h2></div>\n'
'<div data-bbox="100 220 900 420" data-label="Text">'
"<p>La domanda &egrave; pi&ugrave; che fondata, e la facolt&agrave; "
'prevista non &egrave; stata esercitata.<br>Ne consegue l&apos;accoglimento.</p></div>\n'
'<div data-bbox="100 450 900 650" data-label="Table">'
'<table><tr><th>Voce</th><th>Importo</th></tr>'
'<tr><td>Spese</td><td>1.200,00</td></tr></table></div>\n'
'<div data-bbox="100 950 900 990" data-label="Page-Footer"><p>pag. 3</p></div>'
)
# --------------------------------------------------------------- the prompt #
def test_the_layout_prompt_asks_for_boxes_and_labels():
"""Without these two attributes the reply is one unlocated blob, and the engine
has no reason to exist alongside olmOCR."""
assert "data-bbox" in ch.OCR_LAYOUT_PROMPT
assert "data-label" in ch.OCR_LAYOUT_PROMPT
assert "normalized 0-1000" in ch.OCR_LAYOUT_PROMPT
for label in ("Section-Header", "Table", "Figure", "Blank-Page"):
assert label in ch.OCR_LAYOUT_PROMPT
def test_the_plain_prompt_does_not_ask_for_layout():
"""`data-bbox` is still in its shared attribute whitelist — that list is one
constant in both prompts. What the plain prompt lacks is the INSTRUCTION to
arrange the page as blocks and the label set to use."""
assert ch.OCR_PROMPT.startswith("OCR this image to HTML.")
assert "arranged as layout blocks" not in ch.OCR_PROMPT
assert "Section-Header" not in ch.OCR_PROMPT
assert "arranged as layout blocks" in ch.OCR_LAYOUT_PROMPT
def test_both_prompts_carry_the_shared_guidelines():
"""The tag/attribute whitelist and the maths, table and chemistry rules are
trained-on wording, not advice — they belong to both prompts."""
for prompt in (ch.OCR_PROMPT, ch.OCR_LAYOUT_PROMPT):
assert "<math>" in prompt and "KaTeX" in prompt
assert "<chem>" in prompt
assert "colspan" in prompt
def test_the_tag_list_is_rendered_as_pythons_repr():
"""Mirrored from chandra/prompts.py, where it is an f-string of a Python list. The
quoting and spacing are part of the prompt the model was trained on."""
assert "['math', 'br', 'i'" in ch.PROMPT_ENDING
assert "'data-bbox'" in ch.PROMPT_ENDING
def test_layout_is_the_default_and_the_switch_turns_it_off():
eng = ch.ChandraEngine(_cfg())
assert eng._prompt() is ch.OCR_LAYOUT_PROMPT
assert ch.ChandraEngine(_cfg(chandra_layout=False))._prompt() is ch.OCR_PROMPT
# ---------------------------------------------------------------- the parse #
def test_a_block_becomes_a_line_a_region_and_the_page_text():
page = ch.parse_page(REPLY, 1000, 1000)
assert len(page.lines) == 5
assert len(page.regions) == 5
assert "Motivi della decisione" in page.text
assert page.meta["labels"]["Text"] == 1
def test_accents_survive_the_html_entity_decode():
"""The reason this engine is interesting at all: a second model's reading of a page
whose accents Surya loses. If our own parse ate them the comparison would be
worthless — and entity-encoded accents are exactly what an HTML-emitting model sends."""
page = ch.parse_page(REPLY, 1000, 1000)
assert "più" in page.text
assert "facoltà" in page.text
assert "è" in page.text
assert count_unreadable(page.text) == 0
def test_boxes_are_denormalised_against_the_image_the_model_saw():
page = ch.parse_page(REPLY, 2000, 4000)
# data-bbox="100 50 900 90" of 1000 → 20%..90% across, 1.25%..2.25% down.
x0, y0, x1, y1 = page.regions[0].bbox
assert (round(x0), round(y0), round(x1), round(y1)) == (200, 200, 1800, 360)
def test_a_box_that_will_not_parse_keeps_the_text_and_drops_the_box():
"""A full-page default would look like a real answer, and anything cropping by it
would be silently wrong. Missing is better than wrong."""
page = ch.parse_page(
'<div data-bbox="nonsense" data-label="Text"><p>Cfr. Cass.</p></div>', 100, 100)
assert page.lines[0].text == "Cfr. Cass."
assert page.lines[0].bbox == [0.0, 0.0, 0.0, 0.0]
assert page.regions == []
def test_a_degenerate_box_is_refused_too():
assert ch.parse_bbox("500 500 100 100", 1000, 1000) is None # x1 < x0
assert ch.parse_bbox("0 0 0 0", 1000, 1000) is None # empty
assert ch.parse_bbox("1 2 3", 1000, 1000) is None # three numbers
def test_a_table_keeps_its_html():
"""Flattened to text a table has lost the only thing that made it a table."""
page = ch.parse_page(REPLY, 1000, 1000)
assert len(page.tables) == 1
assert "<table>" in page.tables[0]["html"]
assert page.tables[0]["bbox"] is not None
def test_table_cells_do_not_weld_together_in_the_text():
assert ch.html_to_text(
"<table><tr><th>Voce</th><th>Importo</th></tr></table>") == "Voce Importo"
def test_a_blank_page_block_is_dropped():
page = ch.parse_page(
'<div data-bbox="0 0 1000 1000" data-label="Blank-Page"></div>', 100, 100)
assert page.lines == [] and page.regions == [] and page.text == ""
def test_headers_and_footers_can_be_left_out():
keep = ch.parse_page(REPLY, 1000, 1000, include_furniture=True)
drop = ch.parse_page(REPLY, 1000, 1000, include_furniture=False)
assert "TRIBUNALE DI MILANO" in keep.text and "pag. 3" in keep.text
assert "TRIBUNALE DI MILANO" not in drop.text and "pag. 3" not in drop.text
assert "Motivi della decisione" in drop.text
def test_nested_divs_are_one_block_not_several():
"""Layout blocks nest — a Form or a Table block holds divs — and only the
outermost carries the page-level box."""
page = ch.parse_page(
'<div data-bbox="0 0 1000 1000" data-label="Form">'
'<div>interno</div>esterno</div>', 100, 100)
assert len(page.lines) == 1
assert "interno" in page.lines[0].text and "esterno" in page.lines[0].text
def test_a_reply_with_no_blocks_at_all_is_still_a_transcription():
"""The model ignored the layout instruction. It still read the page, and dropping
that would be the worst possible response to an unexpected shape."""
page = ch.parse_page("<p>Solo testo, senza blocchi.</p>", 100, 100)
assert page.text == "Solo testo, senza blocchi."
assert len(page.lines) == 1
def test_content_outside_any_block_is_not_lost():
page = ch.parse_page("preambolo<div data-label=\"Text\"></div>", 100, 100)
assert "preambolo" in page.text
def test_an_unclosed_final_div_still_yields_its_block():
"""Truncated at the token limit, which is routine on a dense page."""
page = ch.parse_page(
'<div data-bbox="0 0 500 500" data-label="Text"><p>mezza frase', 100, 100)
assert "mezza frase" in page.text
def test_math_and_chemistry_survive_as_their_source():
"""Unrendered LaTeX beats a formula silently deleted."""
page = ch.parse_page(
'<div data-label="Equation-Block"><math>E = mc^2</math></div>', 100, 100)
assert "E = mc^2" in page.text
def test_void_tags_do_not_unbalance_the_block_split():
"""<br> and <img> arrive unclosed; counting them as open tags would swallow the
rest of the page into one block."""
page = ch.parse_page(
'<div data-label="Text">a<br>b<img alt="x">c</div>'
'<div data-label="Text">secondo</div>', 100, 100)
assert len(page.lines) == 2
def test_conf_is_zero_because_the_model_reports_none():
"""A number nobody computed must not be handed to a caller that gates on it."""
page = ch.parse_page(REPLY, 1000, 1000)
assert all(l.conf == 0.0 for l in page.lines)
# ------------------------------------------------------------- the scaling #
def test_the_input_is_capped_at_the_same_pixels_as_surya():
"""3072x2048 = 6,291,456 — reached by A4 at about 255 DPI, which is why
chandra_dpi is 250 and not higher."""
from PIL import Image
out = ch.scale_to_fit(Image.new("RGB", (4960, 7016))) # A4 at 600 DPI
assert out.size[0] * out.size[1] <= 3072 * 2048
def test_dimensions_land_on_the_vision_towers_28_pixel_grid():
from PIL import Image
for size in ((2480, 3508), (800, 600), (1, 1)):
out = ch.scale_to_fit(Image.new("RGB", size))
assert out.size[0] % 28 == 0 and out.size[1] % 28 == 0, size
def test_the_boxes_are_read_against_the_scaled_size_not_the_original():
"""They come back normalised against the image the MODEL saw. De-normalising
against the pre-scaling size would put every box on a scaled page in the wrong
place."""
from PIL import Image
eng = ch.ChandraEngine(_cfg())
eng._mode = lambda: "server"
seen = {}
def _ask(uri, temp, top_p):
return '<div data-bbox="0 0 1000 1000" data-label="Text"><p>x</p></div>'
eng._ask = _ask
page = eng.recognize_image(Image.new("RGB", (4960, 7016)))
assert page.width * page.height <= 3072 * 2048
assert page.regions[0].bbox == [0.0, 0.0, float(page.width), float(page.height)]
# …and the size the caller rasterised is still reported, so the scaling is visible.
assert page.meta["source_size"] == [4960, 7016]
# ------------------------------------------------------- repetition loops #
def test_a_repeated_tail_is_detected():
assert ch.detect_repeat_token("testo normale " + "pag. 3 " * 40)
def test_ordinary_prose_is_not():
assert not ch.detect_repeat_token(
"La domanda è più che fondata, e la facoltà prevista non è stata esercitata.")
def test_a_loop_is_re_asked_with_a_higher_temperature():
"""A greedy decode that fell into a loop falls into the same one again, so the
retry has to change something."""
eng = ch.ChandraEngine(_cfg(chandra_max_retries=2))
eng._mode = lambda: "server"
temps, replies = [], ["loop " * 60, "<div data-label=\"Text\"><p>buono</p></div>"]
def _ask(uri, temp, top_p):
temps.append(temp)
return replies[min(len(temps) - 1, len(replies) - 1)]
eng._ask = _ask
content, meta = eng._ask_with_retries("data:,")
assert temps == [0.0, 0.2]
assert meta["retries"] == 1
assert "buono" in content
def test_a_page_that_loops_to_the_end_is_returned_flagged_not_dropped():
"""A looping transcription usually has a correct beginning, and the flag is what
lets a caller decide instead of being handed a silent mess."""
eng = ch.ChandraEngine(_cfg(chandra_max_retries=1))
eng._mode = lambda: "server"
eng._ask = lambda uri, temp, top_p: "loop " * 60
content, meta = eng._ask_with_retries("data:,")
assert meta["repeat_detected"] is True
assert meta["retries"] == 1
assert content
def test_retries_off_accepts_the_first_reply():
eng = ch.ChandraEngine(_cfg(chandra_max_retries=0))
eng._mode = lambda: "server"
calls = []
def _ask(uri, temp, top_p):
calls.append(temp)
return "loop " * 60
eng._ask = _ask
_, meta = eng._ask_with_retries("data:,")
assert len(calls) == 1
assert meta["repeat_detected"] is True
def test_a_clean_first_reply_reports_no_retries():
eng = ch.ChandraEngine(_cfg())
eng._mode = lambda: "server"
eng._ask = lambda uri, temp, top_p: '<div data-label="Text"><p>ok</p></div>'
_, meta = eng._ask_with_retries("data:,")
assert "retries" not in meta and "repeat_detected" not in meta
# ------------------------------------------------------------- the licence #
def test_nothing_loads_until_the_licence_is_accepted():
cfg = _cfg(chandra_accept_license=False)
with pytest.raises(OcrError) as e:
ch.ChandraEngine(cfg).load()
assert e.value.status == 400
assert "OpenRAIL" in str(e.value)
def test_the_refusal_says_what_the_terms_actually_are():
"""An operator deciding whether to tick a box needs the terms, not the name of a
config flag — these are narrower than Surya's GPL."""
cfg = _cfg(chandra_accept_license=False)
with pytest.raises(OcrError) as e:
ch.ChandraEngine(cfg).load()
msg = str(e.value)
assert "$2M" in msg
assert "competing" in msg
assert "chandra_accept_license" in msg
def test_the_engine_is_not_enabled_without_the_licence():
assert not om._engine_enabled(_cfg(chandra_accept_license=False), "chandra")
assert om._engine_enabled(_cfg(), "chandra")
def test_the_catalogue_explains_the_refusal():
mgr = om.OcrManager()
mgr.configure(_cfg(chandra_accept_license=False))
row = next(r for r in mgr.engine_catalogue()["data"] if r["id"] == "chandra")
assert row["enabled"] is False
assert "OpenRAIL" in row["reason"]
# -------------------------------------------------------------- the modes #
def test_model_mode_needs_to_be_told_where_the_model_is():
with pytest.raises(OcrError) as e:
ch.ChandraEngine(_cfg(chandra_serve="model", chandra_model_id="")).load()
assert "chandra_model_id" in str(e.value)
def test_server_mode_needs_a_url():
with pytest.raises(OcrError) as e:
ch.ChandraEngine(_cfg(chandra_serve="server", chandra_server_url="")).load()
assert "chandra_server_url" in str(e.value)
def test_a_server_url_gains_the_v1_it_was_given_without():
eng = ch.ChandraEngine(_cfg(chandra_serve="server",
chandra_server_url="http://10.0.0.5:8000"))
eng.load()
assert eng._base == "http://10.0.0.5:8000/v1"
def test_a_url_that_already_ends_in_v1_is_not_doubled():
eng = ch.ChandraEngine(_cfg(chandra_serve="server",
chandra_server_url="http://10.0.0.5:8000/v1/"))
eng.load()
assert eng._base == "http://10.0.0.5:8000/v1"
def test_an_unknown_serve_mode_is_refused_by_name():
with pytest.raises(OcrError) as e:
ch.ChandraEngine(_cfg(chandra_serve="telepathy")).load()
assert "telepathy" in str(e.value)
def test_model_mode_is_the_only_one_that_needs_no_local_gpu():
"""And it is the mode the Digesta orchestrator runs: a box with no card, whose
whole job is renting one."""
eng = ch.ChandraEngine(_cfg(chandra_serve="model", chandra_model_id="chandra"))
eng.load() # must not raise and must not need vLLM
assert eng._base == ""
# ------------------------------------------------------------ the transport #
class _Reply:
def __init__(self, payload, status=200, text=""):
self._p, self.status_code, self.text = payload, status, text
def json(self):
return self._p
def _ok(content):
return _Reply({"choices": [{"message": {"content": content}}]})
def test_the_request_goes_through_pod_http_so_a_pods_own_ca_is_trusted(monkeypatch):
"""Chandra 'model' mode reaches the upstream the dispatcher chose, which can be a
RunPod pod on the direct-TCP path: https to a bare IP with a certificate signed by
THIS install's CA. Plain requests fails that twice over, which is exactly how
olmOCR came to be unreachable (CERTIFICATE_VERIFY_FAILED)."""
from codai.api import pod_http
seen = {}
def _post(url, json=None, headers=None, timeout=None, **kw):
seen.update(url=url, json=json, headers=headers or {})
return _ok("<div data-label=\"Text\"><p>ok</p></div>")
monkeypatch.setattr(pod_http, "post", _post)
eng = ch.ChandraEngine(_cfg(chandra_serve="server",
chandra_server_url="https://10.0.0.9:8000",
chandra_api_key="tok"))
eng.load()
assert "ok" in eng._ask("data:image/png;base64,AA", 0.0, 0.1)
assert seen["url"] == "https://10.0.0.9:8000/v1/chat/completions"
assert seen["headers"]["Authorization"] == "Bearer tok"
def test_the_image_is_sent_before_the_instruction(monkeypatch):
"""The order chandra's own client builds. For a VLM the order it was trained on is
part of the prompt."""
from codai.api import pod_http
seen = {}
monkeypatch.setattr(pod_http, "post",
lambda url, json=None, **kw: (seen.update(json=json),
_ok("<p>x</p>"))[1])
eng = ch.ChandraEngine(_cfg(chandra_serve="server",
chandra_server_url="http://h:8000"))
eng.load()
eng._ask("data:image/png;base64,AA", 0.0, 0.1)
parts = seen["json"]["messages"][0]["content"]
assert parts[0]["type"] == "image_url"
assert parts[1]["type"] == "text"
def test_sampling_is_greedy_by_default(monkeypatch):
from codai.api import pod_http
seen = {}
monkeypatch.setattr(pod_http, "post",
lambda url, json=None, **kw: (seen.update(json=json),
_ok("<p>x</p>"))[1])
eng = ch.ChandraEngine(_cfg(chandra_serve="server",
chandra_server_url="http://h:8000"))
eng.load()
eng._ask("data:,", 0.0, 0.1)
assert seen["json"]["temperature"] == 0.0
assert seen["json"]["top_p"] == 0.1
assert seen["json"]["max_tokens"] == ch.DEFAULT_MAX_TOKENS
def test_an_unreachable_server_is_a_503_not_a_500(monkeypatch):
from codai.api import pod_http
def _boom(*a, **kw):
raise OSError("connection refused")
monkeypatch.setattr(pod_http, "post", _boom)
eng = ch.ChandraEngine(_cfg(chandra_serve="server",
chandra_server_url="http://h:8000"))
eng.load()
with pytest.raises(OcrError) as e:
eng._ask("data:,", 0.0, 0.1)
assert e.value.status == 503
def test_an_http_error_is_reported_with_its_body(monkeypatch):
from codai.api import pod_http
monkeypatch.setattr(pod_http, "post",
lambda *a, **kw: _Reply({}, status=500, text="CUDA OOM"))
eng = ch.ChandraEngine(_cfg(chandra_serve="server",
chandra_server_url="http://h:8000"))
eng.load()
with pytest.raises(OcrError) as e:
eng._ask("data:,", 0.0, 0.1)
assert e.value.status == 502
assert "CUDA OOM" in str(e.value)
def test_an_empty_reply_is_an_error_not_an_empty_page(monkeypatch):
"""A silently empty page is how a whole document goes missing."""
from codai.api import pod_http
monkeypatch.setattr(pod_http, "post", lambda *a, **kw: _ok(" "))
eng = ch.ChandraEngine(_cfg(chandra_serve="server",
chandra_server_url="http://h:8000"))
eng.load()
with pytest.raises(OcrError):
eng._ask("data:,", 0.0, 0.1)
def test_a_multipart_reply_is_joined():
assert ch._content_of(
{"choices": [{"message": {"content": [{"text": "a"}, {"text": "b"}]}}]}) == "ab"
# --------------------------------------------------------------- the manager #
def test_the_manager_knows_the_engine():
assert "chandra" in om._ENGINE_FACTORIES
def test_it_declares_no_per_instance_vram():
"""An instance is one in-flight page against a shared server. Counting a GB each
asked a 24-instance surya pool to free 24 GB."""
assert ch.ChandraEngine(_cfg()).vram_gb() == 0.0
def test_only_the_vllm_mode_demands_room_before_it_loads():
assert ch.ChandraEngine(_cfg(chandra_serve="server",
chandra_server_url="http://h:1")).prelaunch_vram_gb() == 0.0
assert ch.ChandraEngine(_cfg(chandra_serve="model",
chandra_model_id="x")).prelaunch_vram_gb() == 0.0
def test_the_catalogue_says_it_returns_boxes_regions_and_tables():
mgr = om.OcrManager()
mgr.configure(_cfg())
row = next(r for r in mgr.engine_catalogue()["data"] if r["id"] == "chandra")
assert row["returns"] == {"boxes": True, "regions": True, "tables": True,
"meta": True}
assert row["kind"] == "pipeline"
def test_with_layout_off_the_catalogue_stops_promising_boxes():
"""Because with it off there genuinely are none, and a client choosing on
capability would be choosing on a lie."""
mgr = om.OcrManager()
mgr.configure(_cfg(chandra_layout=False))
row = next(r for r in mgr.engine_catalogue()["data"] if r["id"] == "chandra")
assert row["returns"]["boxes"] is False
assert row["returns"]["regions"] is False
def test_it_is_reachable_by_the_checkpoint_name_as_well_as_the_engine_name():
"""A client asks for OCR with the model id it saw in /v1/models, not with the name
of the pipeline we happen to drive it with."""
mgr = om.OcrManager()
mgr.configure(_cfg())
assert mgr.resolve_engine("datalab-to/chandra-ocr-2") == "chandra"
assert mgr.resolve_engine("chandra") == "chandra"
def test_it_has_its_own_rasterisation_dpi_for_the_same_reason_surya_does():
"""The pixel ceiling is identical — chandra/model/util.py uses the same
3072x2048 — so feeding it the shared 200 wastes the same 38%."""
mgr = om.OcrManager()
mgr.configure(_cfg(dpi=200))
assert mgr.engine_dpi("chandra") == 250
assert mgr.engine_dpi("paddle") == 200
def test_a_dpi_of_zero_falls_back_to_the_shared_one():
mgr = om.OcrManager()
mgr.configure(_cfg(dpi=180, chandra_dpi=0))
assert mgr.engine_dpi("chandra") == 180
def test_lang_is_accepted_but_does_not_steer_it():
"""Its prompt has no language slot, like surya-2's and olmOCR's. Saying so is what
lets a caller tell a language that took effect from one merely accepted."""
assert om.engine_uses_language("chandra") is False
mgr = om.OcrManager()
mgr.configure(_cfg())
row = next(r for r in mgr.engine_catalogue()["data"] if r["id"] == "chandra")
assert row["language_aware"] is False
def test_it_keeps_one_pool_whatever_language_is_asked_for():
"""A language-keyed pool is a second set of loaded instances holding VRAM, for a
parameter this engine cannot act on."""
import asyncio
mgr = om.OcrManager()
mgr.configure(_cfg())
async def _go():
a = await mgr._get_pool("chandra", "it")
b = await mgr._get_pool("chandra", "de")
return a is b
assert asyncio.run(_go())
def test_a_config_change_invalidates_the_pools():
"""Otherwise a settings change appears to save and changes nothing until a
restart."""
base = om.OcrManager._pool_params(_cfg())
for field, value in (("chandra_serve", "server"), ("chandra_model", "other"),
("chandra_layout", False), ("chandra_instances", 4),
("chandra_accept_license", False)):
assert om.OcrManager._pool_params(_cfg(**{field: value})) != base, field
def test_it_is_in_the_automatic_fallback_chain():
"""A 503 means a document nobody read, which is strictly worse than a
transcription from the second-best engine."""
mgr = om.OcrManager()
cfg = _cfg(paddle_enabled=True, fallback_engines="auto")
mgr.configure(cfg)
assert "chandra" in mgr._fallback_order("surya")
# …and it is tried after the engines with fewer ways to fail.
order = mgr._fallback_order("surya")
assert order.index("paddle") < order.index("chandra")
# ------------------------------------------------------------ a rented pod #
def test_a_pod_cannot_accept_the_licence_on_the_operators_behalf():
"""Asking for surya by name IS the GPL acceptance (that is the deployment's own
decision reaching the pod). Chandra's terms are narrower — a revenue ceiling and a
non-compete — so only a decision already recorded in this install's config travels."""
import inspect
from codai.api import runpod_worker as rw
src = inspect.getsource(rw._plan)
assert '_asked == "chandra" and _chandra_accepted()' in src
assert '_asked == "surya" or _surya_accepted()' in src
def test_a_pod_image_without_a_vlm_server_downgrades_chandra_to_doctr():
from codai.api import runpod_worker as rw
assert rw.pod_ocr_engine("chandra") == "doctr"
assert rw.pod_ocr_engine("paddle") == "paddle"
......@@ -824,7 +824,11 @@ def test_the_catalogue_lists_every_engine_with_whether_it_is_enabled():
paddle_enabled=False, doctr_enabled=False)
cat = mgr.engine_catalogue()
rows = {r["id"]: r for r in cat["data"]}
assert set(rows) == {"paddle", "doctr", "surya", "olmocr"}
# Against the REGISTRY, not a hand-written list: the point of the catalogue is that
# a client never has to know which engines exist, and a frozen list here would mean
# adding one silently left it out of the only place that answers that question.
from codai.ocr.manager import _ENGINE_FACTORIES
assert set(rows) == set(_ENGINE_FACTORIES)
assert rows["surya"]["enabled"] is True
assert rows["paddle"]["enabled"] is False
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment