Local OCR and document understanding for .NET: a pure-managed, deterministic reader with
small models of our own, a page model with boxes and calibrated confidences, invoices and
receipts as fields, and optional LLM vision that is checked against the page. Pure C# with no
dependency beyond .NET itself (and the OneImage / OnePDF libraries for pixels and PDFs). No native
binaries, no tessdata, no network unless you configure a provider.
Results against Tesseract, PaddleOCR, docTR and Apple Vision, per category, are in Benchmarks. Every number there comes from one run of the release gate on one machine; the method is written next to the tables so you can repeat it.
Why OneOcr
- Free, fast, local. The recognizer (5 MB), the page detector (1.4 MB) and the two word models
(1.4 MB and 1.3 MB) ship inside the
RecursiveAI.OneOcr.Modelspackage and run on the CPU through our own SIMD kernels. Scripts: Latin (including the EU languages, Turkish and Vietnamese), Greek and Cyrillic in one model. - A page, not a string. The result is an
OcrPage: blocks, lines, words and characters with boxes (and quads for rotated text), tables with row and column spans, key–value pairs, reading order, and aSourceon every element. It serializes to a documented, versioned JSON format,oneocr.page, that shares its member names with OnePDF'sonepdf.extraction(spec). - Confidence you can use. A word's
Confidenceis the calibrated probability that the word is exactly right, calibrated per script on held-out data. The release gate requires an expected calibration error of at most 0.05 in every category. - Same bytes everywhere. Text, boxes and confidences are bit-identical on AVX-512, AVX2, 128-bit vectors (SSE and NEON) and the scalar path, and at every degree of parallelism. The rules are written down in docs/spec/numerics.md.
- Documents as fields.
ExtractInvoice()andExtractReceipt()return typed fields, each with the words and boxes it was read from; a small local model labels words on the page, so it cannot invent a value. Invoice fields carry their EN 16931 business-term ids. - LLM vision that is held to the page. Opt-in providers (Anthropic, OpenAI, OpenAI-compatible
servers, Gemini, Azure OpenAI) over plain
HttpClient. Every value the model returns is checked against the words on the page and comes backGrounded,GroundedNormalized,DerivedorUngrounded, with boxes where it has them. - Train it on your documents. Fine-tune the recognizer or a new extraction head from your own scans, on the CPU, deterministically and resumably, with a review queue for the lines a model and the reader disagree on.
- Safe on files you did not write. Model files, images, page JSON, PDFs, review files and LLM
responses are read by bounds-checked managed code under limits checked before anything large is
allocated, and each of those readers has a fuzz target. The only
unsafecode is in the SIMD kernels, which run on buffers that safe callers validated. NativeAOT and trimming are supported.
Install
dotnet add package RecursiveAI.OneOcrRecursiveAI.OneOcr pulls in RecursiveAI.OneOcr.Models (the weights), so one install gives you
the one-liner. The rest is opt-in:
| Package | What it adds |
|---|---|
RecursiveAI.OneOcr | Local OCR, the page model and oneocr.page JSON, local Markdown, invoice / receipt / custom extraction (inference). No networking code. |
RecursiveAI.OneOcr.Models | The recognizer, page detector and word model, embedded and checked against their SHA-256 pins on every load. |
RecursiveAI.OneOcr.Models.Documents | The invoice and receipt extraction model. |
RecursiveAI.OneOcr.Llm | LLM vision: providers, modes, grounding, cost controls, routing. The only package that uses the network. |
RecursiveAI.OneOcr.Training | OcrTrainer, ExtractionTrainer and the review queue. |
RecursiveAI.OneOcr.Pdf | PDF pages: text layer plus OCR of image regions. |
RecursiveAI.OneOcr.Tool | The oneocr command-line tool: ocr, train, eval, review. |
dotnet add package RecursiveAI.OneOcr.Llm dotnet add package RecursiveAI.OneOcr.Pdf dotnet add package RecursiveAI.OneOcr.Models.Documents dotnet add package RecursiveAI.OneOcr.Training dotnet tool install --global RecursiveAI.OneOcr.Tool
Target framework net10.0. Namespaces: OneOcr (core), OneOcr.Documents, OneOcr.Decoding,
OneOcr.Models.Documents, OneOcr.Llm, OneOcr.Pdf, OneOcr.Training. Pixels come from
RecursiveAI.OneImage, so any OneImage.Image
(PNG, JPEG, WebP, TIFF and so on) is an input. Moving from Tesseract? See the
migration guide.
Quick start
// Once at startup: the recognizer, the page detector and the word model that ship in RecursiveAI.OneOcr.Models. OcrEngine engine = OcrEngine.LoadBundled(); Image image = Image.Load("invoice.png"); string text = image.ToText(engine); // plain text in reading order OcrPage page = image.Ocr(engine); // the page model: blocks, lines, words, characters, tables string markdown = image.ToMarkdown(engine); // headings, lists and pipe tables
An OcrEngine is immutable and thread-safe; load it once and share it. If you would rather not
pass it around, set a process-wide default once (OcrEngine.ConfigureDefault) and use the
extension methods without an engine. Until you do, OcrEngine.Default throws
InvalidOperationException.
OcrEngine.ConfigureDefault(); // once; the bundled models become OcrEngine.Default string text = Image.Load("page.png").ToText(); // the extension methods without an engine use the default OcrPage page = Image.Load("page.png").Ocr();
From the command line (dotnet tool install --global RecursiveAI.OneOcr.Tool):
oneocr ocr --in page.png # text to stdout oneocr ocr --in scans/ --out text/ --json # one .txt and one .page.json per image
ocr reads PNG and JPEG with the bundled models and never touches the network; --degree N caps
the threads (the text does not depend on it). The same tool trains models, see
Training.
Reading a page: OcrEngine and options
ToText and Ocr run the same pipeline; ToText returns the page's text and Ocr returns the
whole page model. The pipeline finds the text lines with the page detector, turns the page upright if it came in
rotated by 90, 180 or 270 degrees, straightens tilted lines, reads every line, decides word
breaks from the line's pixels as well as from the network, drops specks that were read as text,
works out reading order across columns and sidebars, and finds tables.
An engine carries its options (engine.Options; engine.WithOptions(...) returns a copy with
other ones). Every call also takes an OcrOptions?; null means the engine's, and a value
replaces the engine's options as a whole (nothing is merged). An option out of range throws
ArgumentOutOfRangeException naming options.
OcrPage page = image.Ocr(engine, new OcrOptions { Dpi = 300, // when the file does not say; otherwise estimated, with a diagnostic MaxDegreeOfParallelism = 2, // 0 = min(4, cores); the result does not depend on it IncludeChars = false, // skip per-character boxes DetectOrientation = true, // 0/90/180/270 (the default) Deskew = true, // tilts up to about 15 degrees (the default) }); foreach (OcrBlock block in page.Blocks) { foreach (OcrLine line in block.Lines) { foreach (OcrWord word in line.Words) { Console.WriteLine($"{word.Text} {word.Confidence:F3} ({word.Box.Left:F0}, {word.Box.Top:F0})"); } } }
Other OcrOptions switches are SearchAngle (re-reads a badly tilted photo at other page angles when the first reading is weak), DropNoise, FixWordSpacing, FixDigitLookalikes (reads the
letter O as 0 inside identifiers and dates) and FixSeparators (reads a middle dot, bullet or
vertical bar between words from its pixels when the network gave it as a hyphen or another small
mark). These two are the only steps after recognition that change a character. All are on by
default and documented on the type.
- Errors. Public methods throw only the typed exceptions (all derive from
OcrException), argument exceptions, I/O exceptions andOperationCanceledException:OcrFormatException(bytes that are not what they claim to be),OcrLimitException,OcrModelException,OcrProviderExceptionandOcrBudgetException(in the LLM package). Where something is recoverable the page says so inpage.Diagnosticsinstead of throwing. - Threading and async. Every local call takes a
CancellationToken, checked between pages and between lines, and throwsOperationCanceledException. Every local call also has anAsynctwin (OcrAsync,ToTextAsync,ToMarkdownAsync,ExtractInvoiceAsync,ExtractReceiptAsync,ExtractAsync<T>, and the same on PDF pages and documents). A twin runs the very same code on a thread-pool thread, so its result is byte-identical to the sync call, and a cancelled token ends the task as cancelled. Keep theImagealive until the task completes.engine.Ocr(images)andengine.OcrAsync(images)read several images in order, with consecutiveOcrPage.Indexvalues. The LLM package is async only. - Multi-page files.
Ocr(Image)reads one page and throwsArgumentExceptionfor an image with more than one frame, because silently reading the first frame of a TIFF loses pages.OcrPagesreads every frame (engine.OcrPages(path | stream | bytes | image), also as an extension onImage), andToTextandToMarkdownon an image read all frames and join them with a blank line. The number of frames counts againstOcrLimits.MaxPagesPerCall. - Untrusted images. A file OneImage refuses surfaces as
OcrFormatException, and a file overOcrLimits.MaxImagePixels(or too many frames) asOcrLimitException; the original exception is the inner one. The pixel and frame limits are applied before the pixels are decoded when you pass a path, a stream or bytes toOcrPages. - Resolution.
OcrPages(path | stream | bytes)takes the resolution a PNG (pHYs), JPEG (JFIF) or TIFF (XResolution) declares asUnitsPerInch. On anImageyou loaded yourself the resolution is estimated from the size of the text, and a diagnostic says so; setOcrOptions.Dpiwhen you know it.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); OcrPage page = await image.OcrAsync(engine, new OcrOptions { Deskew = false }, cts.Token); string text = await image.ToTextAsync(engine, null, cts.Token); IReadOnlyList<OcrPage> frames = await engine.OcrAsync([image, image], cancellationToken: cts.Token);
- Decoding. Page reading (
Ocr,ToMarkdown) is always greedy CTC, because the word boxes come from the greedy alignment.engine.WithDecoding(...)sets the decoding the plain-text callengine.ToText(image, engine.Decoding)uses: beam search with a character language model or a lexicon.
OcrEngine engine = OcrEngine.Load(modelPath).WithDecoding(new DecodeOptions { Mode = CtcDecoding.Beam, BeamWidth = 8, LanguageModel = CharLanguageModel.Load(lmPath), });
Words that contain digits or code symbols are read exactly as greedy decoding reads them, so the language model never "corrects" an identifier. There is no word dictionary by default.
- Your own model.
OcrEngine.Load(path)reads a model container (for example one you fine-tuned) and.WithDetector(path)adds a detector; without one the classical line finder runs.
The page model and oneocr.page JSON
OcrPage → OcrBlock → OcrLine → OcrWord → OcrChar, plus Regions (roles such as title, list,
table or figure, where the detector reports them), Tables (cells with row and column spans that
point at words), KeyValues and Diagnostics.
| Member | Meaning |
|---|---|
| Coordinates | Top-left origin, y down, in page units: pixels for images, points for PDF pages. Width, Height, UnitsPerInch; PDF pages also carry ToPdf, the matrix back to PDF user space. |
Source | Ocr, TextLayer or Llm, on every element. |
Confidence | Calibrated probability that the word is exactly right. RawConfidence is the uncalibrated mean of the characters' best softmax probabilities. Lines and blocks average their words'. |
| Boxes and quads | Axis-aligned OcrBox everywhere; an OcrQuad where text was rotated or straightened. Character boxes from OCR are approximate (they come from the network's alignment) and say so. |
Orientation, SkewDegrees | How far the page content was turned clockwise from upright (0, 90, 180 or 270) and how far it was straightened before reading. All coordinates stay in the image as you gave it, also on a page that came in rotated: boxes cover the words where they are in your image, and rotated text carries a quad that follows the text. |
Diagnostics | What the reader decided on its own, as OcrDiagnostic(Code, Severity, Message). Codes are stable (OCR101 resolution estimated, OCR102 resolution defaulted, OCR103 page turned, OCR104 page straightened, OCR105 straightening skipped, OCR106 angle searched, OCR107 noise dropped, OCR108 stray accents fixed, OCR109 line skipped, OCR110 no calibration, OCR2xx extraction, OCR301 PDF, OCR401 LLM). The JSON keeps the message only. |
string json = OcrPageJson.ToJson([page], new OcrPageJsonOptions { Indented = true }); IReadOnlyList<OcrPage> again = OcrPageJson.Parse(json); // checked against OcrLimits; throws OcrFormatException on bad input
The JSON is deterministic (the same pages give the same bytes), versioned (`"format":
"oneocr.page","version": 1`), and specified member by member in
docs/spec/oneocr-page-v1.md, including how it differs from
onepdf.extraction (y runs down here, boxes are [left, top, right, bottom]).
Every element is a sealed record that compares by value. You can build a page yourself with
object initializers (new OcrWord { Text = ..., Box = ..., Confidence = ..., Source = ... });
required members are enforced by the compiler.
page.ToMarkdown() turns a page into Markdown without any model call: headings from text size,
lists, pipe tables from detected tables, headers, footers and page numbers dropped (see
MarkdownOptions). page.ToText() joins lines with a newline and table cells with a tab; it is what
image.ToText() returns. page.Text (and the JSON text member) puts a blank line between blocks;
page.ToText(OcrTextLayout.Paragraphs) is the same thing as a method.
Invoices, receipts and your own document types
A small layout-aware model tags the words of a page; deterministic code assembles the fields from
the tagged words. Each ExtractedField has the exact text, a normalized value (an ISO date, a
plain decimal amount, an ISO currency code), the word indices and the box.
DocumentModels.Configure(); // the extraction model from RecursiveAI.OneOcr.Models.Documents, once Invoice invoice = image.ExtractInvoice(engine); Console.WriteLine($"{invoice.Number?.Value} {invoice.IssueDate?.Value} {invoice.Total?.Value} {invoice.Currency?.Value}"); foreach (InvoiceLine line in invoice.Lines) { Console.WriteLine($"{line.Description?.Text} {line.Quantity?.Value} x {line.UnitPrice?.Value} = {line.Amount?.Value}"); } Receipt receipt = image.ExtractReceipt(engine); Console.WriteLine($"{receipt.Merchant?.Text} {receipt.Total?.Value}");
- Invoice fields follow EN 16931 business terms (
BT-1number,BT-2issue date,BT-5currency,BT-27seller name,BT-44buyer name,BT-84IBAN,BT-106subtotal,BT-110tax,BT-112total,BT-115amount due, and so on), plus line items (BT-126toBT-153) and the tax breakdown per rate. Look any field up withinvoice.Extraction.Get("BT-112"). - Receipt fields: merchant, date, time, subtotal, tax, tip, total, payment method and items.
- A field the model did not find is
null, never a guess.Confidenceon an extracted field is the mean probability of its words' tags; it is not a calibrated probability. page.FindKeyValues()pairs keys with values on any page andpage.WithKeyValues()returns the page with the pairs filled in.
Extraction takes a DocumentExtractionOptions (MaxDegreeOfParallelism, Strict) and
DocumentExtractor.Default is the process-wide extractor (DocumentExtractor.ConfigureDefault()).
For your own type, give Extract<T> a source-generated JsonTypeInfo<T> (no reflection, so it
works under NativeAOT) and a model you trained for it (see Training). A property is
filled from the model class with the same JSON name, so name properties after the classes
([JsonPropertyName("BT-112")] for the invoice total). A property with no class keeps its
default and a diagnostic says so (OcrDiagnosticCodes.PropertyUnmapped); with
DocumentExtractionOptions.Strict it throws, and it always throws when no property maps.
result.WordBoxes(name) throws KeyNotFoundException for a name that is not a property:
public sealed record PurchaseOrder(string? Number, string? Supplier, decimal? Total); [JsonSerializable(typeof(PurchaseOrder))] internal sealed partial class OrderContext : JsonSerializerContext;
DocumentExtractor extractor = DocumentExtractor.Load("purchase-orders.onm"); // a model you fine-tuned (see Training) ExtractionResult<PurchaseOrder> result = image.Extract(OrderContext.Default.PurchaseOrder, engine, extractor); Console.WriteLine($"{result.Value.Number} {result.Value.Total}"); IReadOnlyList<OcrBox> where = result.WordBoxes("Total"); // where on the page the value was read
The model format and its inference are specified in docs/spec/docextract-v0.md.
RecursiveAI.OneOcr.Pdf reads each page as one OcrPage: text-layer words wherever the PDF has
them (from OnePDF), and OCR of image regions
that the text does not cover, rendered at 300 DPI through
RecursiveAI.OneImage.Pdf. Every word
keeps its Source.
IReadOnlyList<OcrPage> pages = PdfOcr.Read("contract.pdf", engine, new PdfOcrOptions { RenderDpi = 300 }); foreach (OcrPage page in pages) { Console.WriteLine($"page {page.Index + 1}: {page.Words.Count} words"); } string markdown = pages.ToMarkdown();
Extension methods on OnePDF's PdfPage and PdfDocument (Ocr, ToText, ToMarkdown,
ExtractInvoice, ExtractReceipt, each with an Async twin) do the same on a document you already hold.
- A text layer is not trusted blindly. An invisible layer laid over a scan (a previous OCR pass)
is re-read with our engine unless you set
TrustExistingOcrLayer. Lines that look garbled (U+FFFD, private-use code points, control characters) are re-read from a render, and a cross-check of a few sampled lines drops the whole layer when it disagrees with what the page looks like. - Page coordinates are PDF points, y down;
ToPdfmaps them back to PDF user space. - Not yet: text drawn as outlined glyphs, annotations.
LLM vision, held to the page
RecursiveAI.OneOcr.Llm sends a page image and the local reader's word list (id, text, box) to a
model and asks for text, Markdown, a page model or JSON for your schema in one call. Everything it
returns is checked against the page.
| Provider class | API |
|---|---|
AnthropicProvider | Anthropic Messages |
OpenAiProvider | OpenAI |
OpenAiCompatibleProvider | Ollama, llama.cpp, vLLM, LM Studio and other OpenAI-style servers (a local model needs no key) |
GeminiProvider | Google Gemini |
AzureOpenAiProvider | Azure OpenAI (endpoint and deployment) |
There is no default model: you name the model string. Providers take an injected HttpClient (a
shared one that never follows redirects is used otherwise, so a key header cannot be sent to
another host), write requests with Utf8JsonWriter and read responses with System.Text.Json under
size and depth limits; no vendor SDKs, no reflection. Structured output uses each provider's own
mechanism (OpenAI json_schema, a single forced tool call for Anthropic, responseSchema for
Gemini), and the model is never offered tools to run.
OcrPage page = image.Ocr(engine); LlmProvider provider = AnthropicProvider.FromEnvironment("your-model-name"); // reads ANTHROPIC_API_KEY, because you called it using var llm = new LlmClient(provider, new LlmOptions { MaxCostPerCallUsd = 0.25m, SpendCapUsd = 5m }); LlmReadResult result = await llm.ReadAsync(image, page, new LlmReadOptions { Outputs = LlmOutputs.Text | LlmOutputs.Json, // one call produces every requested output JsonSchema = schemaJson, UngroundedPolicy = UngroundedPolicy.Flag, }); foreach (GroundedField field in result.Fields) { Console.WriteLine($"{field.Path} = {field.Value} [{field.State}, {field.Confidence:F2}] {field.Boxes.Count} box(es)"); }
Outputs (LlmOutputs, combine with |): Text (the model's transcription merged with the
local reading word by word: the local word wins where it is confident, the model's where the
reader is unsure, and each disagreement is listed), Markdown (the model's Markdown with the spans
where it disagrees with the local reader marked), Json (values for your schema, grounded) and
PageModel (the merged page, every word with its Source). ExtractAsync<T> takes a
source-generated JsonTypeInfo<T> instead of a schema string, derives the schema from it and is grounded the
same way: it returns LlmReadResult<T> with the Value and the grounded Fields.
Cost caps work with a local OpenAI-compatible server (host localhost, 127.x.x.x or [::1]), which is priced at 0;
a server on another host needs an entry in the price table (LlmPriceTable.With).
Grounding. For every scalar the model returns it must cite the words it read. We check the cited words against the value after normalizing dates, amounts, currency, whitespace and case; a missing or wrong citation triggers a search of the page. Each value ends in one of five states:
GroundingState | Meaning |
|---|---|
Grounded | The value is the text of words on the page. Boxes provided. |
GroundedNormalized | Equal to words on the page after normalization (for example "Oct 3" and 2026-10-03). Boxes and NormalizedValue provided. |
Derived | The model says it computed or inferred the value. Never boxed; confidence is high only when a sum, difference or product of the cited amounts reproduces it. |
Ungrounded | Nothing on the page supports it: a possible hallucination. |
RoleConflict | The value is on the page, but a local extraction model reads those words as a different field. Counts like Ungrounded. |
Ungrounded values are re-asked once in a second call that shows the mismatch (counted against
your spend), then UngroundedPolicy applies: Flag (default: keep and mark), Null or Throw.
The rules are in docs/spec/grounding.md. One risk grounding cannot
remove, and the spec says so: a value that is printed on the page but sits in the wrong role can
ground. The role check catches this when a local extraction model is available.
Cost controls. An estimate before you call, per-call and per-client USD caps from a dated price table you can replace (a warning appears when it is older than 180 days), a concurrency limit, bounded retries with backoff, and an opt-in result cache keyed by image, prompt, model and schema.
LlmEstimate estimate = llm.Estimate([new LlmPageInput(image, page)], "Read the invoice.", schemaJson); Console.WriteLine($"about {estimate.InputTokens} input tokens, at most ${estimate.WorstCaseCostUsd}");
Routing. LlmRouting.LlmOnly (default), LocalOnly, or LocalFirst: the local extraction
model answers the fields it is confident about and the model is called, with a reduced schema, only
for the rest. When every field is local, no call is made and nothing is sent.
LlmReadResult result = await llm.ReadAsync(image, page, new LlmReadOptions { Outputs = LlmOutputs.Json, JsonSchema = schemaJson, Routing = LlmRouting.LocalFirst, // the model is called only for fields the local model is unsure of LocalExtractor = new DocumentFieldExtractor(extractor), ConfidenceThreshold = 0.9f, }); Console.WriteLine($"{result.Calls} call(s), ${result.CostUsd}");
Privacy.
- Nothing leaves the machine unless you configure a provider; local methods never touch the network, even with a provider configured. There is no telemetry.
- API keys are passed explicitly or through an explicit
FromEnvironment()call; nothing reads environment variables behind your back. Keys are kept in a type that never prints them and are scrubbed from error messages. LlmOptions.Offline = truemakes every call throw.- A redaction hook masks regions before upload, and masked words are left out of grounding:
var options = new LlmOptions { Offline = false, // true makes every call throw OcrProviderException Redactor = (page, pageNumber) => // boxes to mask before upload; masked words are not grounded [.. page.Words.Where(w => w.Text.StartsWith("DE", StringComparison.Ordinal) && w.Text.Length > 15).Select(w => w.Box)], }; using var llm = new LlmClient(OpenAiProvider.FromEnvironment("your-model-name"), options); LlmReadResult result = await llm.ReadAsync(image, image.Ocr(engine), new LlmReadOptions { Outputs = LlmOutputs.Markdown });
LlmProvider.NoRetentionis on by default and sendsstore: falseto OpenAI. Anthropic, Gemini and Azure OpenAI have no per-request switch, so retention there is set by your account agreement with them.- Document text is sent as delimited, untrusted data and the model gets no tools. Grounding stops invented values; the remaining risk (the model picking a different printed number) is described in the grounding spec.
Training
Training in C# is fine-tuning from the shipped weights, on the CPU, with our own backward passes
(convolution with folded batch norm, ReLU, max pooling, bidirectional LSTM, linear layers, CTC and
the extraction transformer), AdamW and checkpoints. Given the same labels, settings and seed, the
weights are the same on every machine and at every degree of parallelism, and a run resumed from a
checkpoint ends with exactly the weights an uninterrupted run would have. Base models are trained
in PyTorch by training/; every layer's forward and backward pass is tested against
PyTorch references.
Recognition. The local reader reads each page and the model you choose transcribes it. A line becomes training data only when the two agree; disagreements go to a review queue you can export, correct and import.
var trainer = new OcrTrainer(new OcrTrainerOptions { ModelPath = "recognizer.onm", WorkDirectory = "run1" }, llm); TrainingReport report = await trainer.RunAsync("scans/"); Console.WriteLine(OcrTrainer.ToJson(report)); LineReview.Export("run1", "review/"); // lines the two readers disagreed on, with crops // ...set "text" on the lines you checked in review/review.json, then: LineReview.Import("run1", "review/review.json");
Extraction. Give a schema; the model reads your pages for its fields; only values that are found on the page become labels (a page with a value that is not on the page is left out entirely); a new tag head is trained on the shipped backbone, which is tuned gently along with it.
ExtractionSchema schema = ExtractionSchema.From(OrderContext.Default.PurchaseOrder); // top-level scalar properties are the fields var trainer = new ExtractionTrainer( new ExtractionTrainerOptions { ModelPath = "docextract.onm", Schema = schema, WorkDirectory = "po-run" }, llm); ExtractionReport report = await trainer.RunAsync("purchase-orders/"); DocumentExtractor tuned = DocumentExtractor.Load(report.ModelPath);
Every run holds out a share of the documents (by content hash) and reports error rates or
per-field accuracy before and after, agreement with the labelling model, and a suggested
LocalFirst threshold. The held-out references are the labelling model's text or grounded values
(or your corrections), so the numbers measure agreement with them; read a few pages yourself before
you rely on them. The same runs are available from the command line:
oneocr train --model recognizer.onm --work run1 --docs scans/ \ --provider openai --llm-model <model> --key-from-env OPENAI_API_KEY --budget-usd 2 oneocr review export --work run1 --out review/ oneocr review import --work run1 --file review/review.json oneocr train --model recognizer.onm --work run1 --restart # trains on the labels already in run1; no network oneocr eval --model run1/model.onm --work run1 oneocr train --extract schema.json --model docextract.onm --work po-run --docs purchase-orders/ \ --provider openai-compatible --base-url http://localhost:8080/v1 --llm-model <local model>
eval, review and train without --docs never touch the network. oneocr help lists every
option.
Provider terms. Using a provider's output to train a model is governed by your own agreement with that provider. As read on 2026-10-23: Anthropic's Commercial Terms of Service (section D.4) bar using the services "to build a competing product or service, including to train competing AI models"; OpenAI's Services Agreement bars using output "to develop artificial intelligence models that compete with OpenAI's products and services", except a model "primarily intended to categorize, classify, or organize data (e.g., embeddings or classifiers), if these models are not distributed or made commercially available to third parties". A model you train for your own internal extraction may fall under that exception; check your own agreement. A local open-weight model behind the OpenAI-compatible provider avoids the question.
Determinism
Text, boxes and confidences hash the same on every kernel path, every operating system and every degree of parallelism. This is a design rule, not luck:
- Dot products and reductions use fused multiply-add with a fixed 16-lane accumulation order:
element k always lands in partial sum k mod 16, whatever the vector width, and the 16
partials reduce in one fixed tree. The scalar path uses
Math.FusedMultiplyAdd; hardware without FMA takes the same scalar path (exact, slower). exp,tanh,sigmoid,logand softmax are our own polynomial implementations with a fixed operation order, never the platform's math library.- Parallelism splits independent outputs; no sum crosses threads. Weights are stored as half precision and computed in single precision.
- Preprocessing, decoding and the page model are written down in docs/spec/ so another implementation can reproduce them.
Tests check every vector path against the scalar reference by hash, over randomized shapes including odd sizes and tails, at degree 1 and at the default degree.
Console.WriteLine(engine.KernelPath); // Avx512, Avx2, Vector128 or Scalar: the text is the same on all four if (engine.PerformanceNotice is { } notice) { Console.WriteLine(notice); // the scalar path is running on a CPU that could do better }
Limits and security
Nothing leaves the machine unless you configure a provider; local methods never touch the network.
- Limits before allocation.
OcrLimitsbounds image pixels (134 million by default), pages per call, lines, words and characters per page, model file and tensor sizes, page-JSON size, LLM response size, schema size, JSON depth and review-import size. Each bound is checked against the declared or running value before the work it guards, and a breach is anOcrLimitExceptionthat names the limit. Recursion never follows input depth.
var limits = new OcrLimits { MaxImagePixels = 50_000_000, MaxLinesPerPage = 1000 }; OcrEngine engine = OcrEngine.Load(modelPath, limits); // OcrLimitException before anything large is allocated
- Untrusted bytes. Model containers, images, page JSON, PDFs, LLM responses, JSON Schemas and
review files are parsed by safe, bounds-checked code with checked arithmetic on every size and
offset taken from the input, and each has a fuzz target under
fuzz/. No crash or hang on any input is a release requirement. unsafeonly in the kernels.unsafe,Unsafe.*andMemoryMarshalappear only undersrc/OneOcr/Kernels/, over buffers that safe callers validated.- No networking in the core package.
HttpClientappears only inRecursiveAI.OneOcr.Llm. There is no telemetry. - No runtime dependencies beyond the .NET base class library, OneImage (pixels) and, for the PDF package, OnePDF and OneImage.Pdf.
NativeAOT
The libraries and the tool are trim- and AOT-clean (no reflection, no runtime code generation).
A NativeAOT binary is compiled for one instruction-set level, and .NET's default for x64
(x86-64-v2) has no AVX2 or FMA. At that level the kernels fall back to their exact scalar
path: the text is the same, but a page takes about a hundred times longer. The
RecursiveAI.OneOcr package therefore sets IlcInstructionSet to x86-64-v3 (AVX2 and FMA, any
x86-64 CPU since about 2015) for x64 NativeAOT publishes whose project has not chosen a level; a
value in your project or on the command line always wins. Use x86-64-v4 for the AVX-512 kernels.
A binary built for v3 refuses to start on an older CPU. Arm needs nothing.
To see what a process runs, read OcrEngine.KernelPath; OcrEngine.PerformanceNotice is non-null
when the scalar path is running on a CPU that could run AVX2 and says how to fix it (an app
published below v3, or DOTNET_EnableAVX2=0 in the environment). With DOTNET_EnableHWIntrinsic=0 the scalar path runs too, but the CPU cannot be queried, so the notice stays null.
It never throws and is not part of the page, so page JSON is the same on every path.
The oneocr tool can be published the same way for fast starts in scripts:
dotnet publish src/OneOcr.Tool -c Release -r linux-x64 (see src/OneOcr.Tool).
Models and licences
Code and model weights are licensed MIT OR Apache-2.0. Weights are stored as half precision in
our own versioned container format (docs/spec/model-container-v1.md),
which is the contract between the PyTorch exporter and the C# reader; user-trained models use the
same format.
| Model | Role | Architecture | Size | Package |
|---|---|---|---|---|
crnn-v0 0.7.3 | Line recognizer: 622 classes (Latin, Greek, Cyrillic, punctuation, currency), per-script calibrated confidence | convolutions, 2-layer BiLSTM, CTC; 2.5 M parameters | 5.0 MB | .Models |
dbnet-lite-v1 0.3.2 | Page detector: text lines (oriented), page orientation, layout classes | DBNet-style, 0.7 M parameters; spec | 1.4 MB | .Models |
charlm-v1 | Character n-gram model for word breaks and optional beam search; spec | interpolated Kneser–Ney, Latin + Greek + Cyrillic | 1.4 MB | .Models |
wordfreq-v2 | Word frequencies that take back a space the recognizer put inside a word; spec | counts of 219 k Latin-script words | 1.3 MB | .Models |
docextract-v0 | Invoice and receipt word tagger; spec | character-CNN word embedding, 2-D position embedding, small transformer | about 4 MB | .Models.Documents |
Training-data attributions and font licences are in THIRD-PARTY-NOTICES.md.
How the models were trained
Everything is reproducible from training/ (a Python project; pinned fetch scripts,
generator seeds and configs). Weights start from random initialization or from our own earlier
checkpoints; no third-party pretrained weights are used, and no weights, images or training data are
committed to the repository.
- Recognizer. Text lines rendered from public-domain books (Project Gutenberg) in 77 open font families (SIL OFL, Apache-2.0 and Ubuntu Font Licence), in Latin, Greek and Cyrillic, then degraded (blur, noise, JPEG, low resolution, faint ink, perspective, uneven light) with exact ground truth; plus word crops of the CORD v2 receipts training split with its labels, synthetic receipt and interface text, text lines cut from screenshots of public-domain US federal web pages (labels from the page's own text), and photos of receipts generated with an image model and transcribed by a language model (see Labels). Held-out font families, held-out books and held-out web sites form the evaluation sets.
- Detector. Page images and text-line boxes from DocLayNet and PubLayNet (only articles under CC0 and CC BY), CORD v2 word quads, synthetic pages (including light-on-dark ones) rendered in Chromium with exact word boxes, and windows of public-domain US federal web pages with the browser's own line boxes.
- Extraction. Generated invoices and receipts with exact field truth, plus CORD v2 training labels.
- Labels. Labels for shipped weights come from exact-truth synthetic data, text in the page source, dataset-provided labels (CORD, DocLayNet, PubLayNet) and permissively licensed open-weight models. The one exception is the generated receipt photos in the recognizer's receipt fine-tune, which a commercial language model transcribed; it is listed in THIRD-PARTY-NOTICES.md. Apart from that, commercial LLM APIs appear only as benchmark rows for the LLM layer. Nobody hand-labels images.
- Clean room. Other OCR systems were studied only through their papers, public documentation and the output of their released binaries. Their source code and model files were never read or used.
Benchmarks
Accuracy result for 1.0, stated as measured: OneOcr is the best engine we measured on five of the ten gated categories and behind the best on five. It beats Tesseract on every category except clean real PDFs, where Tesseract is ahead by about 0.1 point. It is ahead on clean print, degraded scans, phone photos and real scan-degraded and layout PDFs; it trails the best engine (PaddleOCR or Apple Vision) on synthetic screenshots and complex layout (CER and WER; it wins their reading order), real web screenshots, clean real PDFs (narrowly) and generated photos of US receipts.
One run of the release gate on a held-out (sealed) test set that no tuning step saw, scorer v3.1, engine at the 1.0 commit with the bundled models; AMD Ryzen 9 7950X3D, 32 threads, load 5–8 before and after; competitor outputs measured once on the same pages. The clean real-PDF row uses a fresh cut of 27 pages: the first cut was opened up as a development set during development, so it was replaced before this run.
Accuracy, error rate %, CER / WER / reading order (lower is better). For PaddleOCR, docTR and Apple Vision each cell is the best of their published configurations (PaddleOCR PP-OCRv6 and v5 mobile/server, docTR default/mobile, Vision accurate with and without language correction); Tesseract 5.3.4 uses its best page-segmentation mode per metric.
| Category | OneOcr | Tesseract (best) | Tesseract (fast) | PaddleOCR | docTR | Apple Vision | OneOcr vs best |
|---|---|---|---|---|---|---|---|
| Clean 300 DPI print | 0.05 / 0.10 / 0.10 | 0.16 / 0.62 / 0.62 | 0.24 / 0.83 / 0.83 | 0.07 / 0.11 / 0.11 | 0.94 / 2.57 / 2.61 | 0.37 / 0.76 / 0.78 | win / win / win |
| Degraded scans | 0.16 / 0.45 / 0.48 | 0.93 / 2.67 / 2.77 | 1.22 / 3.30 / 3.44 | 0.39 / 1.17 / 1.18 | 1.64 / 4.51 / 13.23 | 0.21 / 0.65 / 0.66 | win / win / win |
| Screenshots and UI | 0.35 / 0.67 / 0.73 | 6.33 / 9.90 / 25.14 | 5.86 / 9.29 / 24.50 | 0.12 / 0.40 / 17.14 | 1.88 / 6.51 / 22.69 | 0.33 / 1.50 / 25.35 | loss / loss / win |
| Complex layout | 0.14 / 0.27 / 0.70 | 13.57 / 11.69 / 13.62 | 13.58 / 11.94 / 13.96 | 0.08 / 0.12 / 62.32 | 1.14 / 3.20 / 63.33 | 0.60 / 1.23 / 22.82 | loss / loss / win |
| Phone photos | 0.21 / 0.67 / 0.81 | 9.34 / 16.48 / 17.87 | 9.40 / 16.21 / 17.48 | 0.39 / 0.83 / 0.83 | 3.41 / 10.54 / 38.07 | 1.16 / 1.90 / 2.45 | win / win / win |
| Receipt photos, US (generated images; recall-CER / recall-WER) | 8.14 / 14.24 | 29.37 / 47.43 | 27.41 / 44.77 | 3.74 / 8.72 | 4.59 / 8.63 | 3.93 / 8.03 | loss / loss |
| Real web screenshots (CER / WER) | 1.98 / 3.14 | 8.62 / 10.93 | 8.76 / 11.66 | 0.88 / 1.63 | 2.89 / 5.16 | 0.81 / 1.63 | loss / loss |
| Real born-digital PDFs: clean | 0.57 / 0.97 / 1.63 | 0.47 / 0.94 / 1.48 | 0.45 / 0.94 / 1.49 | 0.47 / 0.91 / 1.42 | 0.99 / 2.00 / 2.64 | 0.95 / 1.59 / 2.23 | loss / loss / loss |
| Real born-digital PDFs: layout | 0.52 / 1.63 / 3.63 | 1.42 / 4.68 / 6.60 | 1.34 / 4.79 / 6.72 | 0.99 / 4.20 / 84.23 | 1.34 / 5.20 / 84.37 | 2.25 / 5.27 / 8.11 | win / win / win |
| Real PDF pages, scan-degraded | 1.64 / 3.76 / 10.15 | 4.31 / 9.26 / 11.97 | 4.73 / 9.92 / 12.65 | 2.22 / 6.39 / 64.78 | 2.86 / 8.84 / 70.44 | 6.98 / 11.28 / 19.53 | win / win / win |
| Rendered receipts (recall-CER / recall-WER; information) | 0.14 / 0.44 | 5.62 / 10.24 | 5.93 / 10.97 | 1.13 / 1.80 | 3.11 / 10.22 | 0.38 / 1.03 | ahead / ahead |
The four "Real" rows use real pages (rendered public web pages and PDFs with a verified text layer); they are reported apart from the synthetic rows because their truth and their difficulty differ. Receipt photos are judged on recall over the annotated words, and reading order is shown only where it is gated.
Calibration and detection.
| Category | ECE (target ≤ 0.05) | Word-box F1 at IoU 0.5: OneOcr | Tesseract (best) |
|---|---|---|---|
| Clean 300 DPI print | 0.009 | 0.999 | 0.999 |
| Degraded scans | 0.011 | 0.998 | 0.989 |
| Screenshots and UI | 0.012 | 0.991 | 0.797 |
| Complex layout | 0.012 | 0.997 | 0.935 |
| Phone photos | 0.014 | 0.968 | 0.816 |
| Receipt photos, US (generated) | 0.041 (ECE-m; partial truth) | - | - |
| Real web screenshots | 0.012 | 0.980 | 0.904 |
| Real born-digital PDFs: clean | 0.007 | 0.994 | 0.996 |
| Real born-digital PDFs: layout | 0.014 | 0.872 | 0.927 |
| Real PDF pages, scan-degraded | 0.006 | 0.867 | 0.912 |
| Rendered receipts | 0.012 | 0.984 | 0.945 |
Speed and memory, milliseconds per page for the whole process (start-up and model load included),
NativeAOT build of the oneocr tool for x86-64-v4, median of 3 runs. Tesseract 5.3.4 runs one
process per page with one thread and its best page-segmentation mode. Development pages of the same
six kinds (30–100 per set); AMD Ryzen 9 7950X3D, 32 threads, nothing else running (load 3.8 before,
at most 3.4 during, 1.5 after).
| Category | OneOcr, 1 thread | OneOcr, 4 threads | Tesseract fast | Tesseract best | OneOcr peak RSS, MB (target ≤ 300) |
|---|---|---|---|---|---|
| Clean 300 DPI print | 528 | 210 | 677 | 1384 | 118 |
| Degraded scans | 536 | 209 | 540 | 1120 | 140 |
| Complex layout | 698 | 259 | 898 | 1704 | 197 |
| Phone photos | 577 | 232 | 661 | 1162 | 152 |
| Screenshots and UI | 196 | 78 | 226 | 389 | 127 |
| Receipts | 222 | 101 | 289 | 425 | 130 |
On one thread OneOcr is faster than Tesseract with its fast models in every category (degraded scans only just), and about twice as fast as Tesseract with its best models.
Method.
- Metrics. CER and WER are order-free: each truth word is paired with its closest leftover output word. Reading-order error is the edit distance over the word sequence divided by the number of truth words (it includes recognition errors, so read it next to WER). Text is normalized by NFC, typographic quotes and dashes folded to ASCII, ligatures split, whitespace collapsed; case and punctuation are kept; two apostrophes in a row count as one double quote. The truth word sequence is built from the truth's word boxes where it has them (words a page wraps at a hyphen stay separate, a punctuation mark touching a word joins it), the same for every engine; the older text-based scoring is reported beside it. Receipts have partial truth (CORD leaves store and payment blocks unannotated), so they are judged on recall-CER and recall-WER, the error over truth words only. Word-box F1 uses boxes tight to the printed ink. ECE uses 15 equal-width bins over word confidence, a word being correct when it matches its truth exactly.
- Truth. Never produced by an OCR engine: browser layout geometry, the geometry of our own
transforms, a dataset's shipped annotation, or a PDF's text layer checked by two independent
readers. Pages and truth are hashed in manifests; the generators and seeds are in
bench/. - Engines. Tesseract is the distribution's 5.3.4 binary with its own
tessdata_bestandtessdata_fastmodels, run as a black box; for each metric the best of page-segmentation modes 3, 4, 6 and 11 is taken (the hardest baseline). PaddleOCR (PP-OCRv5 mobile and server, PP-OCRv6), docTR (default and mobile) and Apple Vision (accurate) run as released packages with their stock models. Their output is a comparison row, never training data. - Rule. An engine is judged against the best value any measured engine reaches per metric. On clean print, an error within 0.05 percentage points of the best is reported as a tie.
- Speed. One process per run, the shipped
oneocr ocrpublished NativeAOT for the host, three runs, mean; peak RSS from the operating system. Tesseract is timed the same way at one thread and at the same thread count as OneOcr. - Reproduce.
python -m oneocr_bench gateinbench/prints the quality and calibration table for an evaluation directory;bench/scripts/full_run.shruns the whole comparison on one machine.
Not yet
Searchable-PDF output and int8 inference (planned after 1.0); CJK, Arabic and Indic scripts (the page model already has right-to-left and vertical flags, but the local reader does not produce them); handwriting; curved-page dewarping and scene text; text drawn as outlined glyphs in PDFs; AWS Bedrock and Google Vertex providers; a built-in PII redactor (the redaction hook is yours to fill); a word-level character whitelist.
Where to go
- Formats and rules (docs/spec/): the model container, page JSON, numeric rules, decoding, text normalization, Markdown, tables, orientation, grounding, PDF bridge, review queue.
- Migrating: from Tesseract.
- Evidence: benchmark tooling, fuzzing.
- The API reference is the XML documentation your IDE shows.
License
Code and model weights are licensed under either of MIT or Apache-2.0 at your option.
Copyright (c) Recursive AI, LLC. Third-party data and fonts: THIRD-PARTY-NOTICES.md.