ocr: lang= is a real parameter now, and the default is auto-recognition (v0.3.23)

/v1/ocr has accepted `lang` since the beginning, with the comment "reserved": it
was parsed and dropped. A client asking for Italian got whatever the install was
configured for and no way to tell.

It is honoured now, on /v1/ocr, /v1/ocr/batch and the pipeline step, with
precedence request > ocr.<engine>_lang > ocr.lang > auto. The request beats the
engine-specific setting deliberately: that setting is an install's default, while
a caller naming a language knows something about the document in hand. Asking for
`auto` explicitly is NOT the same as sending nothing, so an install configured
for Italian can still be asked to work it out.

The default is auto-recognition rather than any particular language, because a
server that has not been told what it is reading should not assume one. For
surya-2, olmOCR and docTR that is simply what they already do: they are
multilingual and have NO language input to constrain -- surya-2's entire prompt is
"OCR this block image to HTML." and its own module warns the wording is the
training-time contract, so there is nowhere to put a language and nothing to gain.
Rather than let `lang` look effective everywhere, every response now carries what
was applied AND whether the engine that served could act on it
("lang": "it", "lang_applied": false), and /v1/ocr/engines reports the same per
engine so a client can choose before sending.

PaddleOCR is the exception that shapes the rest. It loads a language-specific
recognition model and its options are sent once at load, so an instance is built
for one language and cannot be re-languaged. Hence: `auto` becomes
ocr.paddle_auto_lang (default `latin`, a SCRIPT family rather than detection --
PaddleOCR's own default is `ch`, a worse guess for most documents than no guess),
and paddle gets one pool per language, keyed on the model it is BUILT with so
`auto` and `latin` share a pool instead of loading it twice. The multilingual
engines keep a single pool whatever is asked. That is not tidiness: a
language-keyed pool is a second set of loaded instances holding VRAM, and a
production install runs 24 surya instances. Both pool-iteration sites already use
.values(), and _Pool keeps the plain engine name, so eviction and the VRAM
releaser handle the new keys unchanged.

ocr.lang's shipped default moves "it" -> "auto"; installs that set it explicitly
(Digesta production does) are unaffected. ocr.surya_langs stays as the legacy
setting it is -- it reaches only the <=0.17 recognition API and does nothing on
the pod path.

21 tests. The 8 failures in test_broker_config / test_studio_composed_surfaces /
test_whisper_server_local_models are pre-existing, verified at HEAD.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxYHWbCAjjjD7A1rhFKtaW
parent dc86733f
......@@ -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.22"
__version__ = "0.3.23"
__tagline__ = "Complete orchestration, distribution and escalation of remotizable advanced inference."
# Configure the CUDA caching allocator BEFORE torch is imported anywhere.
......
......@@ -4120,6 +4120,7 @@ def build_settings_dict(c, gpu_cards):
"surya_dpi": c.ocr.surya_dpi,
"max_concurrency": c.ocr.max_concurrency,
"lang": c.ocr.lang,
"paddle_auto_lang": c.ocr.paddle_auto_lang,
"paddle_enabled": c.ocr.paddle_enabled,
"paddle_use_gpu": c.ocr.paddle_use_gpu,
"paddle_instances": c.ocr.paddle_instances,
......@@ -4917,7 +4918,8 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
if "dpi" in d: o.dpi = _i("dpi", o.dpi, 36)
if "surya_dpi" in d: o.surya_dpi = _i("surya_dpi", o.surya_dpi, 0)
if "max_concurrency" in d: o.max_concurrency = _i("max_concurrency", o.max_concurrency, 1)
if "lang" in d: o.lang = (d.get("lang") or "it").strip()
if "lang" in d: o.lang = (d.get("lang") or "auto").strip()
if "paddle_auto_lang" in d: o.paddle_auto_lang = (d.get("paddle_auto_lang") or "latin").strip()
# PaddleOCR
if "paddle_enabled" in d: o.paddle_enabled = bool(d["paddle_enabled"])
if "paddle_use_gpu" in d: o.paddle_use_gpu = bool(d["paddle_use_gpu"])
......
......@@ -1115,9 +1115,22 @@
<label class="form-label">Max concurrency</label>
<input type="number" id="s-ocr-max-concurrency" class="form-input" min="1" placeholder="4">
</div>
<div class="form-group" style="flex:1;min-width:90px">
<div class="form-group" style="flex:1;min-width:140px">
<label class="form-label">Language</label>
<input type="text" id="s-ocr-lang" class="form-input" placeholder="it">
<input type="text" id="s-ocr-lang" class="form-input" placeholder="auto">
<span class="form-hint">Default when a request sends no <code>lang</code>.
<b>auto</b> lets each engine recognise what it finds. Surya-2, olmOCR and docTR
are multilingual and have no language input at all, so <code>lang</code> never
steers them; only PaddleOCR is built for one language.</span>
</div>
<div class="form-group" style="flex:1;min-width:140px">
<label class="form-label">Paddle "auto" language</label>
<input type="text" id="s-ocr-paddle-auto-lang" class="form-input" placeholder="latin">
<span class="form-hint">PaddleOCR has no auto mode, so <b>auto</b> becomes this.
A script family, not detection: <code>latin</code> covers Italian, French,
Spanish, German and the rest of the Latin alphabet. Another script needs naming
(<code>arabic</code>, <code>japan</code>, <code>cyrillic</code>,
<code>devanagari</code>) or one of the multilingual engines.</span>
</div>
<div class="form-group" style="flex:1;min-width:130px">
<label class="form-label">Retry cooldown (s)</label>
......@@ -2122,7 +2135,8 @@ async function loadSettings(){
document.getElementById('s-ocr-dpi').value = ocr.dpi ?? 200;
document.getElementById('s-ocr-surya-dpi').value = ocr.surya_dpi ?? 250;
document.getElementById('s-ocr-max-concurrency').value = ocr.max_concurrency ?? 4;
document.getElementById('s-ocr-lang').value = ocr.lang ?? 'it';
document.getElementById('s-ocr-lang').value = ocr.lang ?? 'auto';
document.getElementById('s-ocr-paddle-auto-lang').value = ocr.paddle_auto_lang ?? 'latin';
document.getElementById('s-ocr-retry-cooldown').value = ocr.build_retry_cooldown_s ?? 60;
document.getElementById('s-ocr-vlm-gmu').value = ocr.vlm_gpu_memory_utilization ?? 0;
document.getElementById('s-ocr-fallback').value = ocr.fallback_engines ?? 'auto';
......@@ -2377,7 +2391,8 @@ async function saveSettings(silent){
dpi: parseInt(document.getElementById('s-ocr-dpi').value) || 200,
surya_dpi: parseInt(document.getElementById('s-ocr-surya-dpi').value) || 0,
max_concurrency: parseInt(document.getElementById('s-ocr-max-concurrency').value) || 4,
lang: document.getElementById('s-ocr-lang').value.trim() || 'it',
lang: document.getElementById('s-ocr-lang').value.trim() || 'auto',
paddle_auto_lang: document.getElementById('s-ocr-paddle-auto-lang').value.trim() || 'latin',
build_retry_cooldown_s: parseFloat(document.getElementById('s-ocr-retry-cooldown').value) || 0,
vlm_gpu_memory_utilization: parseFloat(document.getElementById('s-ocr-vlm-gmu').value) || 0,
fallback_engines: document.getElementById('s-ocr-fallback').value.trim() || 'auto',
......
......@@ -63,7 +63,7 @@ async def create_ocr(
file: UploadFile = File(...),
engine: Optional[str] = Form(None), # paddle|doctr|surya|olmocr (default from config)
dpi: Optional[int] = Form(None), # PDF rasterisation DPI
lang: Optional[str] = Form(None), # reserved (per-engine language override)
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
schema_: Optional[str] = Form(None, alias="schema"), # override extraction schema (JSON string)
detect: Optional[str] = Form(None), # off|layout|detector|both
......@@ -89,6 +89,7 @@ async def create_ocr(
detect=detect,
structured=bool(structured),
schema=schema_,
lang=lang,
)
except OcrError as e:
raise HTTPException(status_code=e.status, detail=str(e))
......@@ -101,6 +102,7 @@ async def create_ocr_batch(
files: List[UploadFile] = File(...),
engine: Optional[str] = Form(None),
dpi: Optional[int] = Form(None),
lang: Optional[str] = Form(None),
structured: Optional[bool] = Form(False),
schema_: Optional[str] = Form(None, alias="schema"),
detect: Optional[str] = Form(None),
......@@ -125,7 +127,7 @@ async def create_ocr_batch(
try:
res = await ocr_manager.ocr_document(
data, filename=name, content_type=ctype, engine=engine, dpi=dpi,
detect=detect, structured=bool(structured), schema=schema_,
detect=detect, structured=bool(structured), schema=schema_, lang=lang,
)
res["filename"] = name
return res
......@@ -226,6 +228,7 @@ class OcrStepRequest(BaseModel):
image: str = "" # data: URI, base64, or {{stepN.url}} reference
engine: Optional[str] = None
dpi: Optional[int] = None
lang: Optional[str] = None
detect: Optional[str] = None
structured: Optional[bool] = False
schema_: Optional[str] = Field(default=None, alias="schema")
......@@ -264,6 +267,7 @@ async def run_ocr_step(request: OcrStepRequest, http_request=None):
return await ocr_manager.ocr_document(
data, filename=filename, engine=request.engine, dpi=request.dpi,
detect=request.detect, structured=bool(request.structured), schema=schema,
lang=getattr(request, "lang", None),
)
except OcrError as e:
raise HTTPException(status_code=e.status, detail=str(e))
......@@ -911,7 +911,17 @@ class OcrConfig:
# rasterisation time and memory for no extra pixels at the model.
surya_dpi: int = 250
max_concurrency: int = 4 # global cap on in-flight OCR pages
lang: str = "it" # default document language
# Default document language when a request names none. "auto" (the shipped
# default) means let the engine recognise what it finds: surya-2, olmOCR and
# docTR are multilingual and have no language input at all, and PaddleOCR --
# the one engine built for a single language -- gets `paddle_auto_lang`.
lang: str = "auto"
# What "auto" becomes for PaddleOCR, which has no auto mode. A SCRIPT family,
# not detection: `latin` covers Italian, French, Spanish, German, Portuguese
# and the rest of the Latin alphabet. PaddleOCR's own default is `ch`, a worse
# guess for most documents than no guess. Another script needs naming
# (lang=arabic|japan|cyrillic|devanagari) or one of the multilingual engines.
paddle_auto_lang: str = "latin"
# After an engine fails to come up, refuse that engine FAST for this long instead of
# re-attempting the load on every request (a vLLM-backed engine burns ~35s per failed
# boot; Surya-2 once did that 338 times in a row on a card that was 11 GB short).
......
......@@ -66,6 +66,104 @@ _ENGINE_FACTORIES = {
}
#: What a request means when it names no language: let the engine recognise
#: whatever is in front of it. Not a guess at a particular language — a server
#: that has not been told what it is reading should not assume one.
#:
#: For surya-2, olmOCR and docTR this is simply the normal mode: they are
#: multilingual and have no language input to constrain (surya-2's whole prompt
#: is ``"OCR this block image to HTML."``). Only PaddleOCR must be handed a
#: concrete language, and ``AUTO_PADDLE_LANG`` is what it gets.
DEFAULT_LANG = "auto"
#: Values a caller may use to mean DEFAULT_LANG explicitly.
_AUTO_ALIASES = {"", "auto", "any", "multi", "multilingual", "detect"}
#: What ``auto`` becomes for PaddleOCR, which has no auto mode and must be
#: constructed for one language.
#:
#: This is a SCRIPT family, not detection: PaddleOCR's ``latin`` model covers
#: Italian, French, Spanish, German, Portuguese and the rest of the Latin
#: alphabet, and most ISO codes it accepts (``it``, ``es``, ...) resolve to it
#: anyway. It is the honest broad default — PaddleOCR's own is ``ch``, which
#: would be a worse guess for most documents than no guess at all. A document in
#: another script needs its language named (``lang=arabic``, ``lang=japan``,
#: ``lang=cyrillic``), or one of the multilingual engines, which need nothing.
#: Override with ``ocr.paddle_auto_lang``.
AUTO_PADDLE_LANG = "latin"
#: Engines whose LOADED instance is built for ONE language, so serving another
#: needs a separate pool.
#:
#: Only PaddleOCR: it loads a language-specific recognition model, and the
#: options are sent once at load (``{"cmd": "load", "opts": ...}``) — an instance
#: cannot be re-languaged afterwards. docTR's recognition is Latin-script-wide,
#: and surya-2 and olmOCR are VLMs whose prompt has no language slot at all
#: (surya-2's is the seven words ``"OCR this block image to HTML."``, and its own
#: module warns the wording is the training-time contract).
#:
#: This set must stay as small as the truth allows. A language-keyed pool is a
#: SECOND set of loaded instances holding VRAM — a production install runs 24
#: surya instances, and multiplying those per language would be ruinous for a
#: parameter those engines cannot use.
_LANG_SENSITIVE = {"paddle"}
def normalize_lang(value) -> str:
"""A language tag reduced to the form engines expect, with the ways of saying
"work it out yourself" all collapsed onto ``auto``."""
text = str(value or "").strip().lower().replace("_", "-")
return DEFAULT_LANG if text in _AUTO_ALIASES else text
def resolve_lang(cfg, name: str, requested=None) -> str:
"""The language this request is read in, or ``auto``.
Precedence is request > engine-specific config > global config > ``auto``. The
REQUEST wins over ``<engine>_lang`` deliberately: a per-engine setting is an
install's default for that engine, while a caller who names a language is
telling us something about the document in hand, which is better information.
A request that explicitly says ``auto`` means auto — it is not treated as
"unset" and does not fall back to the configured language. Asking for
auto-recognition on an install configured for Italian has to be possible.
"""
want = normalize_lang(requested)
if str(requested or "").strip():
return want
per_engine = normalize_lang(getattr(cfg, f"{name}_lang", ""))
if per_engine != DEFAULT_LANG:
return per_engine
return normalize_lang(getattr(cfg, "lang", ""))
def engine_lang(cfg, name: str, lang: str) -> str:
"""``lang`` in the form the engine itself can be built with.
Only PaddleOCR needs the translation: it has no auto mode, so ``auto`` becomes
a broad script model. The multilingual engines never reach here.
"""
if lang != DEFAULT_LANG:
return lang
if name == "paddle":
# Not `normalize_lang(...) or AUTO_PADDLE_LANG`: normalize_lang("") is
# "auto", which is truthy, so an unset override would have returned the
# very value this exists to translate.
configured = normalize_lang(getattr(cfg, "paddle_auto_lang", ""))
return AUTO_PADDLE_LANG if configured == DEFAULT_LANG else configured
return lang
def engine_uses_language(name: str) -> bool:
"""Whether ``name`` actually reads differently when told a language.
Reported to clients so a caller can tell a language that was applied from one
that was accepted and could not be: silently ignoring ``lang`` would be the
worse answer.
"""
return name in _LANG_SENSITIVE
def _engine_enabled(cfg, name: str) -> bool:
if name == "paddle":
return bool(cfg.paddle_enabled)
......@@ -396,6 +494,9 @@ class OcrManager:
def _pool_params(cfg) -> tuple:
return (
cfg.max_concurrency,
# cfg.lang and paddle_auto_lang decide which model a paddle pool is
# built with, so a change to either has to invalidate the pools.
getattr(cfg, "lang", ""), getattr(cfg, "paddle_auto_lang", ""),
cfg.paddle_enabled, cfg.paddle_instances, cfg.paddle_use_gpu,
cfg.paddle_structure, cfg.paddle_lang,
cfg.paddle_det_model_dir, cfg.paddle_rec_model_dir,
......@@ -447,6 +548,11 @@ class OcrManager:
"enabled": bool(enabled),
"instances": _engine_instances(cfg, name),
"returns": shapes.get(name, {}),
# Whether `lang=` steers this engine. The multilingual ones accept
# it and recognise what they find regardless; saying so lets a
# client stop guessing which of the two it got.
"language_aware": engine_uses_language(name),
"lang": resolve_lang(cfg, name, None),
}
if not enabled:
if name == "surya" and cfg.surya_enabled and not cfg.surya_accept_license:
......@@ -536,15 +642,55 @@ class OcrManager:
per_engine = int(getattr(cfg, f"{name}_dpi", 0) or 0)
return per_engine if per_engine > 0 else int(cfg.dpi)
async def _get_pool(self, name: str) -> _Pool:
async def _get_pool(self, name: str, lang: str = "") -> _Pool:
"""The pool serving ``name``, in ``lang`` for the engines that read by it.
A language-sensitive engine gets one pool PER language, because its
instances are built for a language at load time and cannot be re-languaged
(see ``_LANG_SENSITIVE``). Every other engine keeps a single pool whatever
the caller asks for — keying those by language would duplicate loaded
instances, and VRAM, for a parameter they cannot act on.
"""
cfg = self._require_cfg()
if engine_uses_language(name):
# Key on what the engine is BUILT with, not what was asked: `auto` and
# the language it maps to are the same loaded model, and must not be
# two pools holding two copies of it.
lang = engine_lang(cfg, name, resolve_lang(cfg, name, lang))
key = f"{name}:{lang}"
else:
lang, key = "", name
async with self._lock:
pool = self._pools.get(name)
pool = self._pools.get(key)
if pool is None:
cfg = self._require_cfg()
pool = _Pool(name, cfg, _engine_instances(cfg, name))
self._pools[name] = pool
pool = _Pool(name, self._cfg_for(cfg, name, lang),
_engine_instances(cfg, name))
self._pools[key] = pool
return pool
@staticmethod
def _cfg_for(cfg, name: str, lang: str):
"""``cfg`` with this request's language already resolved into it.
The pool builds engines with ``factory(self.cfg)`` and each engine reads its
own language out of the config, so resolving it here means no engine needs
to know a per-request language exists. Both the global and the
engine-specific field are set, so the request beats ``<engine>_lang`` too.
"""
if not lang:
return cfg
try:
import dataclasses
fields = {f.name for f in dataclasses.fields(cfg)}
changes = {"lang": lang}
if f"{name}_lang" in fields:
changes[f"{name}_lang"] = lang
return dataclasses.replace(cfg, **changes)
except Exception:
# Not a dataclass, or a field that will not round-trip: the shared
# config still serves the document, in the configured language.
return cfg
def _fallback_order(self, failed: str) -> List[str]:
"""Enabled engines to try after ``failed`` could not serve the document.
......@@ -565,8 +711,8 @@ class OcrManager:
return [n for n in names
if n != failed and n in _ENGINE_FACTORIES and _engine_enabled(cfg, n)]
async def _pages_with(self, name: str, images: List) -> List[OcrPage]:
pool = await self._get_pool(name)
async def _pages_with(self, name: str, images: List, lang: str = "") -> List[OcrPage]:
pool = await self._get_pool(name, lang)
sem = self._sem or asyncio.Semaphore(4)
async def _one(idx, img):
......@@ -578,7 +724,8 @@ class OcrManager:
pages = await asyncio.gather(*[_one(i, im) for i, im in enumerate(images)])
return list(pages)
async def ocr_pages(self, images: List, engine: Optional[str] = None) -> (str, List[OcrPage]):
async def ocr_pages(self, images: List, engine: Optional[str] = None,
lang: Optional[str] = None) -> (str, List[OcrPage]):
"""OCR a list of PIL page images. Returns (engine_name, [OcrPage]).
The returned name is the engine that ACTUALLY read the pages, which is not
......@@ -588,7 +735,7 @@ class OcrManager:
it fell back."""
name = self.resolve_engine(engine)
try:
return name, await self._pages_with(name, images)
return name, await self._pages_with(name, images, lang)
except Exception as first:
chain = self._fallback_order(name)
if not chain:
......@@ -597,7 +744,7 @@ class OcrManager:
f"falling back to {' -> '.join(chain)}")
for alt in chain:
try:
pages = await self._pages_with(alt, images)
pages = await self._pages_with(alt, images, lang)
except Exception as e:
print(f"[ocr] fallback engine '{alt}' also failed: {e}")
continue
......@@ -609,12 +756,19 @@ class OcrManager:
async def ocr_document(self, data: bytes, filename: str = "", content_type: str = "",
engine: Optional[str] = None, dpi: Optional[int] = None,
detect: Optional[str] = None, structured: bool = False,
schema: Optional[str] = None) -> dict:
schema: Optional[str] = None,
lang: Optional[str] = None) -> dict:
"""OCR raw bytes (image/PDF) end-to-end → response dict.
``detect`` overrides the configured stamp/signature detection mode
(off|layout|detector|both). ``structured`` triggers field extraction via the
configured text model; ``schema`` overrides the extraction schema for this call.
``lang`` is the document's language; omitted or ``auto`` means let the engine
recognise whatever it finds, which is what the multilingual engines do
natively. The response reports the language that was applied and whether the
engine that served the page can actually read by language, so a caller can
tell a language that took effect from one that was merely accepted.
"""
cfg = self._require_cfg()
use_dpi = int(dpi) if dpi else self.engine_dpi(engine)
......@@ -622,7 +776,7 @@ class OcrManager:
# Share the GPU thermal governor with every other workload: block here until
# temps are safe (wait_until_safe blocks, so run it off the event loop).
await asyncio.to_thread(_wait_thermal_safe)
name, pages = await self.ocr_pages(images, engine=engine)
name, pages = await self.ocr_pages(images, engine=engine, lang=lang)
full_text = "\n\n".join(p.text for p in pages)
stamps, signatures = await self._detect_all(images, pages, detect)
......@@ -632,8 +786,15 @@ class OcrManager:
from codai.ocr.extract import extract_fields
structured_out = await extract_fields(full_text, cfg, schema=schema)
applied = resolve_lang(cfg, name, lang)
out = {
"engine": name,
"lang": applied,
# Whether the engine that actually served can read by language at all.
# surya-2, olmOCR and docTR are multilingual with no language input, so
# a `lang` they were given was accepted and had no effect; saying so is
# better than letting a caller believe it steered anything.
"lang_applied": engine_uses_language(name),
"num_pages": len(pages),
"text": full_text,
"pages": [p.to_dict() for p in pages],
......
......@@ -33,7 +33,10 @@ class PaddleEngine(SubprocessOcrEngine):
def _worker_opts(self) -> dict:
return {
"lang": self.cfg.paddle_lang or self.cfg.lang or "it",
# Already resolved by the manager (request > paddle_lang > lang >
# auto), and `auto` already translated to a script family -- an engine
# is built for ONE language and cannot be re-languaged afterwards.
"lang": self.cfg.paddle_lang or self.cfg.lang or "latin",
"use_gpu": bool(self.cfg.paddle_use_gpu),
"structure": bool(self.cfg.paddle_structure),
"det_model_dir": self.cfg.paddle_det_model_dir or "",
......
......@@ -266,7 +266,7 @@ back.
## Enable & configure
Settings → **OCR** card, or `config.json` `"ocr"`. Key fields: `enabled`,
`default_engine`, `dpi`, `surya_dpi`, `max_concurrency`, `lang`, `build_retry_cooldown_s`,
`default_engine`, `dpi`, `surya_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` /
......@@ -295,7 +295,7 @@ aggregate throughput without vLLM (a continuous-batching path is a future option
## Endpoints
```
POST /v1/ocr multipart: file=@doc.pdf [engine=] [dpi=] [detect=] [structured=] [schema=]
POST /v1/ocr multipart: file=@doc.pdf [engine=] [lang=] [dpi=] [detect=] [structured=] [schema=]
POST /v1/ocr/batch multipart: files=@a.pdf files=@b.png [...same fields...]
GET /v1/ocr/engines what this install offers, and what each engine returns
GET/POST/DELETE /v1/ocr/schemas[/{name}] manage extraction schemas
......@@ -320,6 +320,58 @@ page carries `lines[]` (text + bbox + conf), `regions[]` (layout), `tables[]`, a
`meta` when the engine reports page metadata (olmOCR does; the others do not). The
`ocr` **pipeline step type** chains OCR with `text_gen` in custom pipelines.
### `lang=` — and why the default is auto-recognition
A client names the document's language; omitting it (or sending `lang=auto`) means
**let the engine recognise what it finds**. The default is deliberately not a
language: a server that has not been told what it is reading should not assume one.
The parameter does not mean the same thing to every engine, and the response says
which it was:
| engine | `lang=` | why |
|---|---|---|
| `paddle` | **steers it** | loads a language-specific recognition model |
| `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 |
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
steered something:
```json
{ "engine": "surya", "lang": "it", "lang_applied": false, ... }
```
`/v1/ocr/engines` reports the same per engine (`language_aware`), so a client can
choose before sending.
**Precedence** is request > `ocr.<engine>_lang` > `ocr.lang` > auto. The request
beats the engine-specific setting on purpose: that setting is an install's default,
while a caller naming a language knows something about the document in hand. Asking
for `auto` explicitly is *not* the same as sending nothing — an install configured
for Italian can still be asked to work it out.
**PaddleOCR is the exception that shapes the design.** It has no auto mode and its
options are sent once at load (`{"cmd": "load", "opts": ...}`), so an instance is
built for one language and cannot be re-languaged. Two consequences:
* `auto` becomes `ocr.paddle_auto_lang` (default `latin`). That is a *script family*,
not detection — it covers Italian, French, Spanish, German, Portuguese and the rest
of the Latin alphabet, and most ISO codes PaddleOCR accepts resolve to it anyway.
PaddleOCR's own default is `ch`, a worse guess for most documents than no guess.
Another script needs naming: `lang=arabic|japan|cyrillic|devanagari`.
* paddle gets **one pool per language**, keyed on the model it is built with (so
`auto` and `latin` share a pool rather than loading it twice). The multilingual
engines keep a single pool whatever is asked, which is not tidiness: a
language-keyed pool is a second set of loaded instances holding VRAM, and a
production install runs 24 surya instances.
Note that `ocr.surya_langs` is a **legacy** setting. It reaches only the ≤0.17
recognition API (`rec(images, langs, det_predictor)`); surya-2 has no language input,
so on the pod path it does nothing.
### Where `conf` comes from on a served engine
A VLM pipeline has no detector score to report, so surya asks the model server for
......
"""`lang=` was accepted on /v1/ocr and marked "reserved" -- it did nothing.
Now a client names the document's language and we respect it, and when it names
none the default is AUTO-RECOGNITION rather than any particular language. That
asymmetry is the whole design:
* surya-2, olmOCR and docTR are multilingual and have NO language input. Surya-2's
entire prompt is "OCR this block image to HTML." and its own module warns the
wording is the training-time contract, so there is nowhere to put a language and
nothing to gain. For them auto is simply what they already do.
* PaddleOCR loads a language-specific recognition model, and the options are sent
once at load -- an instance cannot be re-languaged. So it is the only engine
that needs a pool per language, and the only one with something to translate
`auto` into.
Keeping that set small is not tidiness: a language-keyed pool is a SECOND set of
loaded instances holding VRAM, and a production install runs 24 surya instances.
"""
import pathlib
import sys
ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from codai.ocr import manager as om # noqa: E402
class _Cfg:
def __init__(self, **kw):
self.lang = "auto"
self.paddle_lang = ""
self.paddle_auto_lang = ""
for k, v in kw.items():
setattr(self, k, v)
# ------------------------------------------------------------ what auto means #
def test_nothing_specified_means_auto():
assert om.resolve_lang(_Cfg(), "surya", None) == "auto"
assert om.DEFAULT_LANG == "auto"
def test_the_ways_of_saying_auto_all_mean_auto():
for word in ("", "auto", "AUTO", " any ", "multi", "multilingual", "detect"):
assert om.normalize_lang(word) == "auto", word
def test_a_named_language_is_normalised_not_mangled():
assert om.normalize_lang(" EN ") == "en"
assert om.normalize_lang("pt_BR") == "pt-br"
# ------------------------------------------------------------- precedence #
def test_the_request_wins_over_every_configured_language():
"""A caller naming a language knows something about the document in hand;
a configured one is only an install's default."""
cfg = _Cfg(lang="it", paddle_lang="french")
assert om.resolve_lang(cfg, "paddle", "japan") == "japan"
def test_the_engine_specific_setting_beats_the_global_one():
cfg = _Cfg(lang="it", paddle_lang="french")
assert om.resolve_lang(cfg, "paddle", None) == "french"
def test_the_global_setting_is_used_when_there_is_nothing_else():
assert om.resolve_lang(_Cfg(lang="it"), "surya", None) == "it"
def test_asking_for_auto_explicitly_is_not_the_same_as_asking_for_nothing():
"""An install configured for Italian must still be able to serve a request
that says "work it out yourself"."""
cfg = _Cfg(lang="it")
assert om.resolve_lang(cfg, "surya", None) == "it"
assert om.resolve_lang(cfg, "surya", "auto") == "auto"
# --------------------------------------------- which engines can act on it #
def test_only_paddle_reads_by_language():
assert om.engine_uses_language("paddle")
for name in ("surya", "olmocr", "doctr"):
assert not om.engine_uses_language(name), name
def test_auto_becomes_a_script_family_for_paddle_which_has_no_auto_mode():
assert om.engine_lang(_Cfg(), "paddle", "auto") == "latin"
assert om.AUTO_PADDLE_LANG == "latin"
def test_the_paddle_auto_language_is_configurable():
assert om.engine_lang(_Cfg(paddle_auto_lang="arabic"), "paddle", "auto") == "arabic"
def test_a_named_language_is_passed_to_paddle_untouched():
assert om.engine_lang(_Cfg(), "paddle", "japan") == "japan"
def test_auto_stays_auto_for_an_engine_that_needs_no_translation():
assert om.engine_lang(_Cfg(), "surya", "auto") == "auto"
# ------------------------------------------------------------ pool identity #
def _pools_for(monkeypatch, cfg, asks):
"""Resolve each (engine, lang) ask against a fresh manager; return the pool
objects and the keys they landed under."""
import asyncio
class _StubPool:
def __init__(self, name, cfg, size):
self.name, self.cfg, self.size = name, cfg, size
monkeypatch.setattr(om, "_Pool", _StubPool)
monkeypatch.setattr(om, "_engine_instances", lambda c, n: 1)
m = om.OcrManager()
m._require_cfg = lambda: cfg
m._pools = {}
m._lock = asyncio.Lock()
async def _run():
return [await m._get_pool(n, lg) for (n, lg) in asks]
got = asyncio.run(_run())
return got, set(m._pools)
def test_two_languages_are_two_paddle_pools(monkeypatch):
got, keys = _pools_for(monkeypatch, _Cfg(),
[("paddle", "it"), ("paddle", "japan")])
assert got[0] is not got[1]
assert keys == {"paddle:it", "paddle:japan"}
def test_auto_and_the_language_it_maps_to_share_one_pool(monkeypatch):
"""They are the same loaded model; two pools would be two copies in VRAM."""
got, keys = _pools_for(monkeypatch, _Cfg(),
[("paddle", "auto"), ("paddle", "latin")])
assert got[0] is got[1]
assert keys == {"paddle:latin"}
def test_a_multilingual_engine_keeps_one_pool_whatever_is_asked(monkeypatch):
"""24 surya instances must not become 24 per language for a parameter the
engine cannot use."""
got, keys = _pools_for(monkeypatch, _Cfg(),
[("surya", "it"), ("surya", "japan")])
assert got[0] is got[1]
assert keys == {"surya"}
def test_a_paddle_pool_is_built_with_the_language_resolved_into_its_config(monkeypatch):
from codai.config import OcrConfig
got, _ = _pools_for(monkeypatch, OcrConfig(), [("paddle", "japan")])
assert got[0].cfg.lang == "japan"
# ------------------------------------------- the config handed to an engine #
def test_the_resolved_language_is_written_into_the_config_the_engine_reads():
"""The pool builds engines with factory(cfg) and each engine reads its own
language out of the config, so resolving it here means no engine has to know
a per-request language exists."""
import dataclasses
from codai.config import OcrConfig
cfg = OcrConfig(lang="it", paddle_lang="french")
out = om.OcrManager._cfg_for(cfg, "paddle", "japan")
assert out.lang == "japan"
# the request has to beat paddle_lang too, or the override would be ignored
assert out.paddle_lang == "japan"
assert dataclasses.is_dataclass(out)
# and the original is untouched -- other pools are still serving from it
assert cfg.lang == "it" and cfg.paddle_lang == "french"
def test_no_language_leaves_the_config_exactly_as_it_was():
from codai.config import OcrConfig
cfg = OcrConfig(lang="it")
assert om.OcrManager._cfg_for(cfg, "surya", "") is cfg
def test_a_config_that_is_not_a_dataclass_does_not_crash_the_request():
assert om.OcrManager._cfg_for(_Cfg(), "paddle", "japan") is not None
# ------------------------------------------------------------- what we report #
def test_the_catalogue_says_which_engines_language_actually_steers():
from codai.config import OcrConfig
m = om.OcrManager()
m._require_cfg = lambda: OcrConfig(paddle_enabled=True, surya_enabled=True,
surya_accept_license=True)
rows = {r["id"]: r for r in m.engine_catalogue()["data"]}
assert rows["paddle"]["language_aware"] is True
assert rows["surya"]["language_aware"] is False
def test_the_shipped_default_is_auto_not_a_language():
from codai.config import OcrConfig
assert OcrConfig().lang == "auto"
assert OcrConfig().paddle_auto_lang == "latin"
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