Recursive AI

OneOcr

All oneocr docs

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.

.NET 10 License

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.Models package 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 a Source on every element. It serializes to a documented, versioned JSON format, oneocr.page, that shares its member names with OnePDF's onepdf.extraction (spec).
  • Confidence you can use. A word's Confidence is 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() and ExtractReceipt() 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 back Grounded, GroundedNormalized, Derived or Ungrounded, 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 unsafe code is in the SIMD kernels, which run on buffers that safe callers validated. NativeAOT and trimming are supported.

Install

Terminal
dotnet add package RecursiveAI.OneOcr

RecursiveAI.OneOcr pulls in RecursiveAI.OneOcr.Models (the weights), so one install gives you the one-liner. The rest is opt-in:

PackageWhat it adds
RecursiveAI.OneOcrLocal OCR, the page model and oneocr.page JSON, local Markdown, invoice / receipt / custom extraction (inference). No networking code.
RecursiveAI.OneOcr.ModelsThe recognizer, page detector and word model, embedded and checked against their SHA-256 pins on every load.
RecursiveAI.OneOcr.Models.DocumentsThe invoice and receipt extraction model.
RecursiveAI.OneOcr.LlmLLM vision: providers, modes, grounding, cost controls, routing. The only package that uses the network.
RecursiveAI.OneOcr.TrainingOcrTrainer, ExtractionTrainer and the review queue.
RecursiveAI.OneOcr.PdfPDF pages: text layer plus OCR of image regions.
RecursiveAI.OneOcr.ToolThe oneocr command-line tool: ocr, train, eval, review.
Terminal
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

C#
// 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.

C#
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):

Terminal
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.

C#
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 and OperationCanceledException: OcrFormatException (bytes that are not what they claim to be), OcrLimitException, OcrModelException, OcrProviderException and OcrBudgetException (in the LLM package). Where something is recoverable the page says so in page.Diagnostics instead of throwing.
  • Threading and async. Every local call takes a CancellationToken, checked between pages and between lines, and throws OperationCanceledException. Every local call also has an Async twin (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 the Image alive until the task completes. engine.Ocr(images) and engine.OcrAsync(images) read several images in order, with consecutive OcrPage.Index values. The LLM package is async only.
  • Multi-page files. Ocr(Image) reads one page and throws ArgumentException for an image with more than one frame, because silently reading the first frame of a TIFF loses pages. OcrPages reads every frame (engine.OcrPages(path | stream | bytes | image), also as an extension on Image), and ToText and ToMarkdown on an image read all frames and join them with a blank line. The number of frames counts against OcrLimits.MaxPagesPerCall.
  • Untrusted images. A file OneImage refuses surfaces as OcrFormatException, and a file over OcrLimits.MaxImagePixels (or too many frames) as OcrLimitException; 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 to OcrPages.
  • Resolution. OcrPages(path | stream | bytes) takes the resolution a PNG (pHYs), JPEG (JFIF) or TIFF (XResolution) declares as UnitsPerInch. On an Image you loaded yourself the resolution is estimated from the size of the text, and a diagnostic says so; set OcrOptions.Dpi when you know it.
C#
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 call engine.ToText(image, engine.Decoding) uses: beam search with a character language model or a lexicon.
C#
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.

MemberMeaning
CoordinatesTop-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.
SourceOcr, TextLayer or Llm, on every element.
ConfidenceCalibrated 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 quadsAxis-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, SkewDegreesHow 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.
DiagnosticsWhat 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.
C#
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.

C#
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-1 number, BT-2 issue date, BT-5 currency, BT-27 seller name, BT-44 buyer name, BT-84 IBAN, BT-106 subtotal, BT-110 tax, BT-112 total, BT-115 amount due, and so on), plus line items (BT-126 to BT-153) and the tax breakdown per rate. Look any field up with invoice.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. Confidence on 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 and page.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:

C#
public sealed record PurchaseOrder(string? Number, string? Supplier, decimal? Total);
 
[JsonSerializable(typeof(PurchaseOrder))]
internal sealed partial class OrderContext : JsonSerializerContext;
C#
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.

PDF

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.

C#
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; ToPdf maps 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 classAPI
AnthropicProviderAnthropic Messages
OpenAiProviderOpenAI
OpenAiCompatibleProviderOllama, llama.cpp, vLLM, LM Studio and other OpenAI-style servers (a local model needs no key)
GeminiProviderGoogle Gemini
AzureOpenAiProviderAzure 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.

C#
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:

GroundingStateMeaning
GroundedThe value is the text of words on the page. Boxes provided.
GroundedNormalizedEqual to words on the page after normalization (for example "Oct 3" and 2026-10-03). Boxes and NormalizedValue provided.
DerivedThe 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.
UngroundedNothing on the page supports it: a possible hallucination.
RoleConflictThe 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.

C#
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.

C#
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 = true makes every call throw.
  • A redaction hook masks regions before upload, and masked words are left out of grounding:
C#
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.NoRetention is on by default and sends store: false to 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.

C#
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.

C#
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:

Terminal
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, log and 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.

C#
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. OcrLimits bounds 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 an OcrLimitException that names the limit. Recursion never follows input depth.
C#
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.
  • unsafe only in the kernels. unsafe, Unsafe.* and MemoryMarshal appear only under src/OneOcr/Kernels/, over buffers that safe callers validated.
  • No networking in the core package. HttpClient appears only in RecursiveAI.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.

ModelRoleArchitectureSizePackage
crnn-v0 0.7.3Line recognizer: 622 classes (Latin, Greek, Cyrillic, punctuation, currency), per-script calibrated confidenceconvolutions, 2-layer BiLSTM, CTC; 2.5 M parameters5.0 MB.Models
dbnet-lite-v1 0.3.2Page detector: text lines (oriented), page orientation, layout classesDBNet-style, 0.7 M parameters; spec1.4 MB.Models
charlm-v1Character n-gram model for word breaks and optional beam search; specinterpolated Kneser–Ney, Latin + Greek + Cyrillic1.4 MB.Models
wordfreq-v2Word frequencies that take back a space the recognizer put inside a word; speccounts of 219 k Latin-script words1.3 MB.Models
docextract-v0Invoice and receipt word tagger; speccharacter-CNN word embedding, 2-D position embedding, small transformerabout 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.

CategoryOneOcrTesseract (best)Tesseract (fast)PaddleOCRdocTRApple VisionOneOcr vs best
Clean 300 DPI print0.05 / 0.10 / 0.100.16 / 0.62 / 0.620.24 / 0.83 / 0.830.07 / 0.11 / 0.110.94 / 2.57 / 2.610.37 / 0.76 / 0.78win / win / win
Degraded scans0.16 / 0.45 / 0.480.93 / 2.67 / 2.771.22 / 3.30 / 3.440.39 / 1.17 / 1.181.64 / 4.51 / 13.230.21 / 0.65 / 0.66win / win / win
Screenshots and UI0.35 / 0.67 / 0.736.33 / 9.90 / 25.145.86 / 9.29 / 24.500.12 / 0.40 / 17.141.88 / 6.51 / 22.690.33 / 1.50 / 25.35loss / loss / win
Complex layout0.14 / 0.27 / 0.7013.57 / 11.69 / 13.6213.58 / 11.94 / 13.960.08 / 0.12 / 62.321.14 / 3.20 / 63.330.60 / 1.23 / 22.82loss / loss / win
Phone photos0.21 / 0.67 / 0.819.34 / 16.48 / 17.879.40 / 16.21 / 17.480.39 / 0.83 / 0.833.41 / 10.54 / 38.071.16 / 1.90 / 2.45win / win / win
Receipt photos, US (generated images; recall-CER / recall-WER)8.14 / 14.2429.37 / 47.4327.41 / 44.773.74 / 8.724.59 / 8.633.93 / 8.03loss / loss
Real web screenshots (CER / WER)1.98 / 3.148.62 / 10.938.76 / 11.660.88 / 1.632.89 / 5.160.81 / 1.63loss / loss
Real born-digital PDFs: clean0.57 / 0.97 / 1.630.47 / 0.94 / 1.480.45 / 0.94 / 1.490.47 / 0.91 / 1.420.99 / 2.00 / 2.640.95 / 1.59 / 2.23loss / loss / loss
Real born-digital PDFs: layout0.52 / 1.63 / 3.631.42 / 4.68 / 6.601.34 / 4.79 / 6.720.99 / 4.20 / 84.231.34 / 5.20 / 84.372.25 / 5.27 / 8.11win / win / win
Real PDF pages, scan-degraded1.64 / 3.76 / 10.154.31 / 9.26 / 11.974.73 / 9.92 / 12.652.22 / 6.39 / 64.782.86 / 8.84 / 70.446.98 / 11.28 / 19.53win / win / win
Rendered receipts (recall-CER / recall-WER; information)0.14 / 0.445.62 / 10.245.93 / 10.971.13 / 1.803.11 / 10.220.38 / 1.03ahead / 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.

CategoryECE (target ≤ 0.05)Word-box F1 at IoU 0.5: OneOcrTesseract (best)
Clean 300 DPI print0.0090.9990.999
Degraded scans0.0110.9980.989
Screenshots and UI0.0120.9910.797
Complex layout0.0120.9970.935
Phone photos0.0140.9680.816
Receipt photos, US (generated)0.041 (ECE-m; partial truth)--
Real web screenshots0.0120.9800.904
Real born-digital PDFs: clean0.0070.9940.996
Real born-digital PDFs: layout0.0140.8720.927
Real PDF pages, scan-degraded0.0060.8670.912
Rendered receipts0.0120.9840.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).

CategoryOneOcr, 1 threadOneOcr, 4 threadsTesseract fastTesseract bestOneOcr peak RSS, MB (target ≤ 300)
Clean 300 DPI print5282106771384118
Degraded scans5362095401120140
Complex layout6982598981704197
Phone photos5772326611162152
Screenshots and UI19678226389127
Receipts222101289425130

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_best and tessdata_fast models, 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 ocr published 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 gate in bench/ prints the quality and calibration table for an evaluation directory; bench/scripts/full_run.sh runs 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

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.