OCR Service — local Tesseract in the cluster
Scanned pages and image attachments are read by a local OCR engine, never a cloud service.
By default that engine is Tesseract 5, run as one stateless pod per instance from core's
deploy/ocr. It is built and shipped the same way as the two local model services that came
before it, deploy/whisper and deploy/s1-extract:
- the engine's data, here the language packs, is baked into the image;
- CI builds the image and pushes it to the fleet registry;
- the image line is a declared release channel;
- a standalone chart runs it, and only an approved chart apply installs it.
portal (MeshWeaver.Ocr, Ocr:Engine=HttpService)
│ POST http://ocr:8080/ocr?languages=de,id (image bytes)
▼
ocr pod — Tesseract 5.5 + eng deu fra ita ind + osd NetworkPolicy: ingress from the portal's
│ {"text": "…", "languages": ["de","id"], namespace only, egress: none
│ "engine": "tesseract 5.5.0"}
The contract
The client is the HttpService engine of MeshWeaver.Plugins' MeshWeaver.Ocr. The service
implements that engine's contract exactly:
| Request | POST {Endpoint}, with the image bytes as the body and Content-Type: image/*. Optional ?languages=de,id hints in ISO 639-1, plus X-File-Name and an optional Authorization: Bearer {ApiKey} |
| Answer | 200 {"text": "…", "languages": ["de", "id"], "engine": "tesseract 5.5.0"}. Languages are ISO 639-1, the language with the most text first |
| Anything else | a JSON {"error": "…"} with 401 / 411 / 413 / 415 / 422 / 503 / 504, which the client reports as Failed |
There is no default engine. A portal whose configuration declares none answers NotConfigured and
sends an image nowhere (policy declared-not-derived). This service is what the declaration
points at.
Languages: baked, detected, hinted
Baked. The image carries eng, deu, fra, ita and ind, plus osd. These are Debian's
tesseract-ocr-* packages, Tesseract's tessdata_fast models under Apache-2.0. Indonesian is
in the set on purpose. Adding a language is a Dockerfile line, a new image, and an entry in the
language-identification table (deploy/ocr/app/ocr.py). Without that entry the language is read
but never reported.
Detected. With no hints, the page is read with every pack in the default set, and the text is
then identified paragraph by paragraph. Each language has a list of distinctive function words.
A word two languages share counts for neither (di is Italian and Indonesian, la is French and
Italian). A paragraph is assigned only with at least two hits and a clear lead. A language is
reported when its paragraphs hold at least 15 % of the assigned words. Tesseract itself has no
language identification. What osd contributes is the script and the orientation. A page
it reads as rotated is turned upright before recognition, and a phone photo's EXIF orientation is
applied first.
Hinted. A hint is the caller saying what the page is. It restricts the set (languages=de
reads with deu alone). It may also extend past a narrowed default: an instance can set
defaultLanguages: de,en, and a caller can still ask for id. A hint with no baked pack is
refused with 422, naming the installed packs, because reading with a pack the caller did not ask
for answers a different question. A page read with one pack whose text the identifier cannot
assign reports that pack's language. A blank page reports none.
Bounds
| Bound | Default | Enforced |
|---|---|---|
| body size | 20 MiB (the client's Ocr:MaxImageBytes) |
from Content-Length, before the body is read |
| pixels | 40 MP (an A4 page at 600 dpi is about 35 MP) | from the image header, before decoding, so a decompression bomb never reaches Tesseract |
| time | 150 s per image, slot wait included | the Tesseract process is killed at the deadline, and the answer is 504. That is under the client's 180 s Ocr:TimeoutSeconds, so the service, not the client, names the cause |
| admission | 8 requests per pod whose body is held at once (limits.maxAdmitted, at least concurrency) |
a place is taken before the body is read and released on every exit; with none free the answer is 503 and the body is never read. So buffered uploads stay under maxAdmitted × maxBytes (160 MiB by default), well inside the 2 GiB memory limit, however many callers arrive at once |
| concurrency | 2 Tesseract processes per pod, one thread each | the admitted rest wait inside their own deadline |
| network | ingress from the named namespaces only, no egress | the chart's NetworkPolicy. The pod mounts no ServiceAccount token |
Proof it works, and that the proof can fail
The image build is the test, as for s1-extract. The stage that ships runs two things.
app/test_server.py is offline, with a fake engine. app/smoke.py runs the real engine and packs
through the real HTTP contract on deploy/ocr/fixtures/de-id.png, a German paragraph above an
Indonesian one. The smoke asserts six words, including the umlaut in Gebäude, which eng alone
reads as Gebaude. It also asserts the languages {de, id}, three times: with no hints, with
languages=de,id, with the page rotated 90°, and with the page as black ink on a transparent
background whose hidden colour is also black — the text then lives only in the alpha channel, and the
service composites it onto white before reading (dropping alpha would hand Tesseract one black page).
Its negative controls are part of the same run:
- the identical German+Indonesian assertion must fail on
fixtures/en.png; - a blank page must come back with no text and no languages;
languages=enmust report neither German nor Indonesian.
.github/workflows/ocr-image.yml adds checks a build cannot show:
- it re-runs the smoke with German removed from the default set and is red unless the smoke fails on the German assertion — the exit code must be the smoke's own failure count, never a container that did not start or a smoke that crashed;
- it runs the image's own
CMD(uid 10001, read-only root) and calls it from a sibling container; - it renders the chart three ways: nothing with
enabled: false, exactly Deployment + Service + NetworkPolicy withegress: []when enabled, and a refusal when enabled without a tag or with a moving one (tesseract-5.5,latest): the chart accepts onlytesseract-<line>-<hex sha>.
On main the workflow pushes meshweaver.azurecr.io/ocr:tesseract-5.5-<short sha>. That tag is
immutable, and it is the one an estate overlay names. It also pushes :tesseract-5.5, the moving
line for a laptop, which is declared in .github/release-channels.json so retention never prunes
it. Both are read back from the registry. The base image comes from Docker Hub in the build, as
for the other images core builds (Docker Hub in CI, What this does not cover).
Enabling it on an instance
The chart is disabled by default, and nothing in core turns it on. Enabling OCR on an instance
changes what that instance's cluster runs and where its documents go, so it is a
deployment-record change the owner approves. It is never a hand helm install, and never a
change to the image pin of the portal. In the estate's deployment repository (for the fleet,
Systemorph/Memex), following the whisper precedent (Deployments/memex-cloud-whisper):
- Vendor the chart. Copy
deploy/ocr/helmtodeployments/aks/<env>/ocr/helm.scripts/check-vendored-charts.pyholds it byte-identical. - Write the values overlay. In
values.<env>.yaml, setenabled: true,image.repository: meshweaver.azurecr.io/ocr, the immutableimage.tagprinted in the publishing run's summary, andnetworkPolicy.allowedNamespaces: [<env namespace>]. Optionally adddefaultLanguagesandauth.existingSecret. - Record the chart release. Add a
Hosting/ChartReleaserecordDeployments/<env>-ocrthat names the chart, the values file and the releaseocr. The owner then approves theApplyChartInstanceAction that installs it. - Point the portal at it. On the instance's deployment record, set
extraPortalConfig:Ocr__Engine = HttpServiceandOcr__HttpService__Endpoint = http://ocr:8080/ocr. The portal chart renders both keys only when they are set (deploy/helm/templates/memex-portal/config.yaml). Then roll the portal through the usual governed action. - Verify on the running instance.
/readyzon the pod names the five packs. A scanned document ingested there carries its text andlanguages.
The client half, the HttpService engine, ships with MeshWeaver.Plugins' MeshWeaver.Ocr. Step 4
does nothing on a portal whose build does not include it.
Related
- End Vision — Document Ingestion: G10 (images to a local OCR extractor) and G11 (local model services), which this page partly delivers.
- Centralized speech: the whisper precedent, a model baked into an image behind one HTTP contract.
- Release Editions: the declared
tesseract-5.5service line.