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. ...@@ -276,13 +276,18 @@ registered in the embedding model category.
### Document OCR ### Document OCR
A dedicated OCR subsystem (`/v1/ocr`) — real OCR engines, **not** a VLM prompted to read, 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 - **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 `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 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 - **Input**: images or PDFs (rasterised at a configurable DPI), single or batch
- **Output**: full text plus per-page `lines[]` with bounding boxes and confidence, - **Output**: full text plus per-page `lines[]` with bounding boxes and confidence,
`regions[]` (layout), `tables[]`, and `meta` where the engine reports page metadata `regions[]` (layout), `tables[]`, and `meta` where the engine reports page metadata
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
# Canonical product version for CoderAI — single source of truth. Both the API # Canonical product version for CoderAI — single source of truth. Both the API
# metadata and the admin web UI read from here. # 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." __tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere. # Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
...@@ -4166,6 +4166,22 @@ def build_settings_dict(c, gpu_cards): ...@@ -4166,6 +4166,22 @@ def build_settings_dict(c, gpu_cards):
"olmocr_temperature": c.ocr.olmocr_temperature, "olmocr_temperature": c.ocr.olmocr_temperature,
"olmocr_timeout": c.ocr.olmocr_timeout, "olmocr_timeout": c.ocr.olmocr_timeout,
"olmocr_retry_rotation": c.ocr.olmocr_retry_rotation, "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_mode": c.ocr.detect_mode,
"detect_model_path": c.ocr.detect_model_path, "detect_model_path": c.ocr.detect_model_path,
"detect_conf": c.ocr.detect_conf, "detect_conf": c.ocr.detect_conf,
...@@ -4968,6 +4984,23 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad ...@@ -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_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_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 "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 # detection
if "detect_mode" in d: o.detect_mode = (d.get("detect_mode") or "off").strip() 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() if "detect_model_path" in d: o.detect_model_path = (d.get("detect_model_path") or "").strip()
......
This diff is collapsed.
...@@ -138,7 +138,7 @@ STEP_PARAMS = { ...@@ -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")], "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_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")], "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: ...@@ -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. #: 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 #: 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. #: 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): def _describe(model: str):
......
...@@ -18,8 +18,10 @@ ...@@ -18,8 +18,10 @@
POST /v1/ocr — OCR an uploaded image or PDF with a purpose-built OCR engine 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. (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 Those three are NOT vision LLMs. Two document VLMs are available for pages the
document VLM for pages the detector-based engines read badly (it returns no boxes). Structured-JSON extraction and stamp/signature detection are layered 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). on in later milestones (fields already accepted, no-op until then).
""" """
...@@ -61,7 +63,7 @@ def _ocr_config(): ...@@ -61,7 +63,7 @@ def _ocr_config():
@router.post("/v1/ocr", summary="OCR an image or PDF with a dedicated OCR engine") @router.post("/v1/ocr", summary="OCR an image or PDF with a dedicated OCR engine")
async def create_ocr( async def create_ocr(
file: UploadFile = File(...), 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 dpi: Optional[int] = Form(None), # PDF rasterisation DPI
lang: Optional[str] = Form(None), # document language; omit or "auto" to let the engine decide 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 structured: Optional[bool] = Form(False), # structured JSON extraction via text model
......
...@@ -980,13 +980,13 @@ def volume_env(mount: str) -> dict: ...@@ -980,13 +980,13 @@ def volume_env(mount: str) -> dict:
def pod_ocr_engine(requested: str) -> str: def pod_ocr_engine(requested: str) -> str:
"""The OCR engine a pod will actually serve for a request naming ``requested``. """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 Mirrors the env a pod is launched with: the VLM engines (surya, olmOCR,
does not carry, so a pod serves docTR for it. The gateway uses this to chandra) need a model server the image does not carry, so a pod serves docTR
rewrite the request's own `engine` field, because the pod honours what the for them. The gateway uses this to rewrite the request's own `engine` field,
request asks for over its configured default. because the pod honours what the request asks for over its configured default.
""" """
r = (requested or "").strip().lower() r = (requested or "").strip().lower()
if r in ("surya", "olmocr"): if r in ("surya", "olmocr", "chandra"):
return "doctr" return "doctr"
return r if r in ("paddle", "doctr") else "doctr" return r if r in ("paddle", "doctr") else "doctr"
...@@ -1001,6 +1001,16 @@ def _surya_accepted() -> bool: ...@@ -1001,6 +1001,16 @@ def _surya_accepted() -> bool:
return False 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(): def _ocr_cfg():
"""This install's OcrConfig, or None. The pod has no config of its own — every """This install's OcrConfig, or None. The pod has no config of its own — every
OCR setting it runs with is seeded from here.""" OCR setting it runs with is seeded from here."""
...@@ -1014,7 +1024,8 @@ def _ocr_cfg(): ...@@ -1014,7 +1024,8 @@ def _ocr_cfg():
def _ocr_external_server(engine: str): def _ocr_external_server(engine: str):
"""The off-box OpenAI-compatible endpoint configured for a VLM OCR engine, or "". """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 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 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 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): ...@@ -1027,6 +1038,10 @@ def _ocr_external_server(engine: str):
mode = str(getattr(o, "olmocr_serve", "") or "").strip().lower() mode = str(getattr(o, "olmocr_serve", "") or "").strip().lower()
url = str(getattr(o, "olmocr_server_url", "") or "").strip() url = str(getattr(o, "olmocr_server_url", "") or "").strip()
return url if (mode == "server" and url) else "" 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": if engine == "surya":
mode = str(getattr(o, "surya_serve", "") or "").strip().lower() mode = str(getattr(o, "surya_serve", "") or "").strip().lower()
url = str(getattr(o, "surya_server_url", "") or "").strip() url = str(getattr(o, "surya_server_url", "") or "").strip()
...@@ -1125,12 +1140,13 @@ def _plan(engine, image, args, mcfg, api_key, entry, served, ...@@ -1125,12 +1140,13 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
_cands = [str((entry or {}).get("alias") or "").strip().lower(), _cands = [str((entry or {}).get("alias") or "").strip().lower(),
str(served or "").strip().lower(), str(served or "").strip().lower(),
str((entry or {}).get("path") 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]) _cands[-1])
# Remember what was ASKED for: the licence decision below is made by asking # 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. # for surya by name, and that has to survive a downgrade to docTR.
_asked = wanted _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 # 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 # 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 # 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, ...@@ -1154,6 +1170,14 @@ def _plan(engine, image, args, mcfg, api_key, entry, served,
_val = str(getattr(_o, _a, "") or "").strip() _val = str(getattr(_o, _a, "") or "").strip()
if _val: if _val:
env[_v] = _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: else:
env["CODERAI_OCR_SURYA_SERVE"] = "llamacpp" env["CODERAI_OCR_SURYA_SERVE"] = "llamacpp"
env["CODERAI_OCR_SURYA_SERVER_URL"] = _srv env["CODERAI_OCR_SURYA_SERVER_URL"] = _srv
...@@ -1165,7 +1189,7 @@ def _plan(engine, image, args, mcfg, api_key, entry, served, ...@@ -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"ocr.{wanted}_server_url and the served mode) — this OCR "
f"request will be served by docTR", flush=True) f"request will be served by docTR", flush=True)
wanted = "doctr" 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. # The pod images ship docTR, the only engine that runs in-process.
wanted = "doctr" wanted = "doctr"
env["CODERAI_OCR_DEFAULT_ENGINE"] = wanted env["CODERAI_OCR_DEFAULT_ENGINE"] = wanted
...@@ -1189,6 +1213,14 @@ def _plan(engine, image, args, mcfg, api_key, entry, served, ...@@ -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 # the decision made HERE — it cannot make it for itself, and asking
# for surya by name is that decision. # for surya by name is that decision.
env["CODERAI_OCR_SURYA_ACCEPT_LICENSE"] = "1" 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) eng = engine_pod_backend(entry or {}, mcfg)
if eng: if eng:
......
...@@ -886,6 +886,10 @@ class OcrConfig: ...@@ -886,6 +886,10 @@ class OcrConfig:
best transcription fidelity on hard pages (old scans, maths, tables, multi-column), 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 but NO bounding boxes. Served through coderai's own model manager, its vLLM
backend, or any OpenAI-compatible endpoint (``olmocr_serve``). 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 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 stays lean. Concurrency on a single GPU comes from loading multiple instances of
...@@ -897,7 +901,7 @@ class OcrConfig: ...@@ -897,7 +901,7 @@ class OcrConfig:
stamp/signature flags (``detect_*``). stamp/signature flags (``detect_*``).
""" """
enabled: bool = False 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 dpi: int = 200 # PDF rasterisation DPI
# Per-engine PDF rasterisation DPI (0 = use `dpi` above). Surya-2 gets its own # 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 # because its input is capped at 3072x2048 = 6.29 Mpixels: `scale_to_fit` in
...@@ -932,7 +936,7 @@ class OcrConfig: ...@@ -932,7 +936,7 @@ class OcrConfig:
# page it was mid-way through. 0 = tear down immediately. # page it was mid-way through. 0 = tear down immediately.
evict_drain_timeout_s: float = 60.0 evict_drain_timeout_s: float = 60.0
# The STARTING share of the card a VLM OCR engine's own vLLM instance asks for # 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 # 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 # 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 # ~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: ...@@ -955,7 +959,7 @@ class OcrConfig:
# When the chosen engine cannot serve a document, try these engines instead rather # 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 # 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 # 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 # "off"/"none" = no fallback (fail with the original error); or an explicit
# comma/space separated order. # comma/space separated order.
fallback_engines: str = "auto" fallback_engines: str = "auto"
...@@ -1050,6 +1054,49 @@ class OcrConfig: ...@@ -1050,6 +1054,49 @@ class OcrConfig:
olmocr_timeout: float = 300.0 # per-page ceiling on the model call 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 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] # --- stamp / signature detection --- [O3]
detect_mode: str = "off" # off|layout|detector|both detect_mode: str = "off" # off|layout|detector|both
detect_model_path: str = "" # YOLO signature/stamp weights (detector/both modes) detect_model_path: str = "" # YOLO signature/stamp weights (detector/both modes)
...@@ -1682,6 +1729,22 @@ class ConfigManager: ...@@ -1682,6 +1729,22 @@ class ConfigManager:
"olmocr_temperature": self.config.ocr.olmocr_temperature, "olmocr_temperature": self.config.ocr.olmocr_temperature,
"olmocr_timeout": self.config.ocr.olmocr_timeout, "olmocr_timeout": self.config.ocr.olmocr_timeout,
"olmocr_retry_rotation": self.config.ocr.olmocr_retry_rotation, "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_mode": self.config.ocr.detect_mode,
"detect_model_path": self.config.ocr.detect_model_path, "detect_model_path": self.config.ocr.detect_model_path,
"detect_conf": self.config.ocr.detect_conf, "detect_conf": self.config.ocr.detect_conf,
...@@ -1762,7 +1825,7 @@ class ConfigManager: ...@@ -1762,7 +1825,7 @@ class ConfigManager:
# the three default to off. Enabling the subsystem alone got a pod as # 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 # far as "OCR engine 'surya' is not enabled" — a second refusal from
# a second flag, one round later. # 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"): if _flag(f"CODERAI_OCR_{name.upper()}_ENABLED"):
setattr(self.config.ocr, f"{name}_enabled", True) setattr(self.config.ocr, f"{name}_enabled", True)
# Where that engine's interpreter lives. A pod image that ships # Where that engine's interpreter lives. A pod image that ships
...@@ -1785,6 +1848,10 @@ class ConfigManager: ...@@ -1785,6 +1848,10 @@ class ConfigManager:
("CODERAI_OCR_SURYA_MODEL", "surya_model"), ("CODERAI_OCR_SURYA_MODEL", "surya_model"),
("CODERAI_OCR_SURYA_MODEL_ID", "surya_model_id"), ("CODERAI_OCR_SURYA_MODEL_ID", "surya_model_id"),
("CODERAI_OCR_SURYA_SERVER_URL", "surya_server_url"), ("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")): ("CODERAI_OCR_FALLBACK_ENGINES", "fallback_engines")):
_val = (_os.environ.get(_env) or "").strip() _val = (_os.environ.get(_env) or "").strip()
if _val: if _val:
...@@ -1793,6 +1860,11 @@ class ConfigManager: ...@@ -1793,6 +1860,11 @@ class ConfigManager:
# Surya is GPL and gated on an explicit acceptance. The pod # Surya is GPL and gated on an explicit acceptance. The pod
# inherits the decision made here; it cannot make it itself. # inherits the decision made here; it cannot make it itself.
self.config.ocr.surya_accept_license = True 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 # 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 # reason OCR does, and an engines pod is rented to run exactly one of
......
...@@ -1188,6 +1188,12 @@ class FrontProxy: ...@@ -1188,6 +1188,12 @@ class FrontProxy:
external = mode == "server" and bool( external = mode == "server" and bool(
(getattr(o, "olmocr_server_url", "") or "").strip()) (getattr(o, "olmocr_server_url", "") or "").strip())
local.append(not external) 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) return bool(local) and not any(local)
def _resident_on(self, engine, key: str) -> bool: def _resident_on(self, engine, key: str) -> bool:
......
...@@ -76,7 +76,7 @@ class ModelCapabilities: ...@@ -76,7 +76,7 @@ class ModelCapabilities:
# Retrieval / document # Retrieval / document
reranking: bool = False # cross-encoder query↔document reranking (bge-reranker, …) 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]: def to_list(self) -> List[str]:
out = [] out = []
......
...@@ -5807,6 +5807,9 @@ class MultiModelManager: ...@@ -5807,6 +5807,9 @@ class MultiModelManager:
_ocr_engines.append("surya") _ocr_engines.append("surya")
if getattr(ocr, "olmocr_enabled", False): if getattr(ocr, "olmocr_enabled", False):
_ocr_engines.append("olmocr") _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: for _eng in _ocr_engines:
_add(_eng, "ocr", {"capabilities": ["ocr"], "backend": "ocr"}) _add(_eng, "ocr", {"capabilities": ["ocr"], "backend": "ocr"})
except Exception: except Exception:
......
This diff is collapsed.
...@@ -58,11 +58,17 @@ def _olmocr_factory(cfg): ...@@ -58,11 +58,17 @@ def _olmocr_factory(cfg):
return OlmOcrEngine(cfg) return OlmOcrEngine(cfg)
def _chandra_factory(cfg):
from codai.ocr.chandra import ChandraEngine
return ChandraEngine(cfg)
_ENGINE_FACTORIES = { _ENGINE_FACTORIES = {
"paddle": _paddle_factory, "paddle": _paddle_factory,
"doctr": _doctr_factory, "doctr": _doctr_factory,
"surya": _surya_factory, "surya": _surya_factory,
"olmocr": _olmocr_factory, "olmocr": _olmocr_factory,
"chandra": _chandra_factory,
} }
...@@ -173,6 +179,11 @@ def _engine_enabled(cfg, name: str) -> bool: ...@@ -173,6 +179,11 @@ def _engine_enabled(cfg, name: str) -> bool:
return bool(cfg.surya_enabled and cfg.surya_accept_license) return bool(cfg.surya_enabled and cfg.surya_accept_license)
if name == "olmocr": if name == "olmocr":
return bool(getattr(cfg, "olmocr_enabled", False)) 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 return False
...@@ -368,9 +379,9 @@ class _Pool: ...@@ -368,9 +379,9 @@ class _Pool:
eng: OcrEngine = await self._q.get() eng: OcrEngine = await self._q.get()
self._enter_page() self._enter_page()
try: try:
# Engines that call back into coderai's async API (olmOCR 'model' mode) run # Engines that call back into coderai's async API (olmOCR/Chandra 'model'
# in a worker thread and need a live loop to hand the coroutine to; give them # mode) run in a worker thread and need a live loop to hand the coroutine to;
# the app's own rather than letting them spin up a second one. # give them the app's own rather than letting them spin up a second one.
try: try:
eng._host_loop = asyncio.get_running_loop() eng._host_loop = asyncio.get_running_loop()
except Exception: except Exception:
...@@ -441,6 +452,7 @@ class OcrManager: ...@@ -441,6 +452,7 @@ class OcrManager:
# reclaim it; the next OCR request re-boots it via ensure_service. # reclaim it; the next OCR request re-boots it via ensure_service.
freed += self._stop_surya_vllm() freed += self._stop_surya_vllm()
freed += self._stop_olmocr_vllm() freed += self._stop_olmocr_vllm()
freed += self._stop_chandra_vllm()
if freed: if freed:
print(f"[ocr] released ~{freed:.1f} GB (pools + Surya vLLM torn down for VRAM eviction)") print(f"[ocr] released ~{freed:.1f} GB (pools + Surya vLLM torn down for VRAM eviction)")
return freed return freed
...@@ -490,6 +502,28 @@ class OcrManager: ...@@ -490,6 +502,28 @@ class OcrManager:
print(f"[ocr] olmOCR vLLM stop skipped: {e}") print(f"[ocr] olmOCR vLLM stop skipped: {e}")
return 0.0 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 @staticmethod
def _pool_params(cfg) -> tuple: def _pool_params(cfg) -> tuple:
return ( return (
...@@ -509,6 +543,12 @@ class OcrManager: ...@@ -509,6 +543,12 @@ class OcrManager:
getattr(cfg, "olmocr_serve", "model"), getattr(cfg, "olmocr_model", ""), getattr(cfg, "olmocr_serve", "model"), getattr(cfg, "olmocr_model", ""),
getattr(cfg, "olmocr_model_id", ""), getattr(cfg, "olmocr_server_url", ""), getattr(cfg, "olmocr_model_id", ""), getattr(cfg, "olmocr_server_url", ""),
getattr(cfg, "olmocr_longest_side", 1288), 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), getattr(cfg, "vlm_gpu_memory_utilization", 0.0),
) )
...@@ -530,14 +570,24 @@ class OcrManager: ...@@ -530,14 +570,24 @@ class OcrManager:
"doctr": {"boxes": True, "regions": True, "tables": False, "meta": False}, "doctr": {"boxes": True, "regions": True, "tables": False, "meta": False},
"surya": {"boxes": True, "regions": True, "tables": True, "meta": False}, "surya": {"boxes": True, "regions": True, "tables": True, "meta": False},
"olmocr": {"boxes": False, "regions": False, "tables": True, "meta": True}, "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 # A pipeline drives a model somebody else serves; an engine owns its own
# weights and runs in process. The distinction decides whether "where does # weights and runs in process. The distinction decides whether "where does
# it run" is even a question, so it is worth saying out loud. # it run" is even a question, so it is worth saying out loud.
kinds = {"paddle": "engine", "doctr": "engine", kinds = {"paddle": "engine", "doctr": "engine",
"surya": "pipeline", "olmocr": "pipeline"} "surya": "pipeline", "olmocr": "pipeline", "chandra": "pipeline"}
models = {"surya": ("surya_model_id", "surya_model"), 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 = [] data = []
for name in sorted(_ENGINE_FACTORIES): for name in sorted(_ENGINE_FACTORIES):
...@@ -558,6 +608,13 @@ class OcrManager: ...@@ -558,6 +608,13 @@ class OcrManager:
if name == "surya" and cfg.surya_enabled and not cfg.surya_accept_license: if name == "surya" and cfg.surya_enabled and not cfg.surya_accept_license:
row["reason"] = ("Surya's licence has not been accepted " row["reason"] = ("Surya's licence has not been accepted "
"(ocr.surya_accept_license)") "(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: else:
row["reason"] = f"not enabled (ocr.{name}_enabled)" row["reason"] = f"not enabled (ocr.{name}_enabled)"
# The ids that ALSO resolve to this engine, so a client can name the # The ids that ALSO resolve to this engine, so a client can name the
...@@ -593,7 +650,8 @@ class OcrManager: ...@@ -593,7 +650,8 @@ class OcrManager:
""" """
cfg = self._require_cfg() cfg = self._require_cfg()
for engine, fields in (("surya", ("surya_model_id", "surya_model")), 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: for field in fields:
got = str(getattr(cfg, field, "") or "").strip().lower() got = str(getattr(cfg, field, "") or "").strip().lower()
if got and got == wanted: if got and got == wanted:
...@@ -705,7 +763,7 @@ class OcrManager: ...@@ -705,7 +763,7 @@ class OcrManager:
if raw in ("off", "none", "no", "false"): if raw in ("off", "none", "no", "false"):
return [] return []
if raw in ("auto", "", "on", "true"): if raw in ("auto", "", "on", "true"):
names = ["paddle", "doctr", "olmocr", "surya"] names = ["paddle", "doctr", "olmocr", "chandra", "surya"]
else: else:
names = [n.strip() for n in raw.replace(",", " ").split() if n.strip()] names = [n.strip() for n in raw.replace(",", " ").split() if n.strip()]
return [n for n in names return [n for n in names
......
This diff is collapsed.
This diff is collapsed.
...@@ -824,7 +824,11 @@ def test_the_catalogue_lists_every_engine_with_whether_it_is_enabled(): ...@@ -824,7 +824,11 @@ def test_the_catalogue_lists_every_engine_with_whether_it_is_enabled():
paddle_enabled=False, doctr_enabled=False) paddle_enabled=False, doctr_enabled=False)
cat = mgr.engine_catalogue() cat = mgr.engine_catalogue()
rows = {r["id"]: r for r in cat["data"]} 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["surya"]["enabled"] is True
assert rows["paddle"]["enabled"] is False 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