Template-based Optical Mark Recognition (OMR) sample package for .NET 9. The library reads filled bubbles on a scanned answer sheet (PNG/JPEG or a scanned PDF page), aligns the page to a JSON template, and returns structured results.
| Project / Folder | Use it for |
|---|---|
| lib | Pre-compiled Release assemblies for Omr.Engine |
| src/Omr.Pdf | Run the engine over a multi-page scanned PDF |
| src/Omr.Poc | End-to-end console sample: generate a PDF, decode QR, grade, annotate, write CSV |
| samples/templates/exam-cs101-v3.json | Example exam template |
The engine does not grade papers, send email, or require WinForms. The POC demonstrates those as replaceable callbacks.
- .NET 9 SDK
- Network access to restore NuGet packages (SkiaSharp, Syncfusion PDF, ZXing for the POC)
- Optional:
SYNCFUSION_LICENSE— without a key, Syncfusion may watermark generated PDFs
Linux CI/dev images already use SkiaSharp.NativeAssets.Linux.NoDependencies.
dotnet build Omr.slnThe console app generates a synthetic multi-page exam PDF, recognizes each page, applies a sample grading formula, and writes outputs to a folder.
dotnet run --project src/Omr.Poc -- --pages 4 --out ./poc-output| Argument | Default | Meaning |
|---|---|---|
--pages |
3 |
Number of answer sheets to generate and process |
--out |
./poc-output |
Output directory |
Outputs
| File | Contents |
|---|---|
exam-batch.pdf |
Generated input batch |
page-NNN.json |
Recognition result for that page |
page-NNN.png |
Annotated correction image |
annotated.pdf |
All annotated pages |
summary.csv |
Student id, grade, answers, review flag, paths |
QR payloads in the sample look like exam-cs101|v3|student-001. Ctrl+C cancels a run.
Page variants in the sample: all correct, one wrong answer, one blank, one double-mark (needs review).
Reference lib/Omr.Engine.dll from your app.
using Omr.Engine;
using Omr.Engine.Templates;
OmrTemplate template = OmrTemplate.Load("exam-cs101-v3.json");
template.EnsureValid();
var options = new OmrRecognitionOptions
{
FilledThreshold = 0.70f,
BlankThreshold = 0.20f,
AutoRotate = true,
Deskew = true,
MinAlignmentScore = 0.35f
};
using var processor = new OmrProcessor(options);
Omr.Engine.Results.OmrPageResult result = processor.Recognize("scan.png", template);
Console.WriteLine($"{result.PageStatus} align={result.AlignmentScore:0.00}");
foreach (var group in result.Groups)
{
Console.WriteLine($"{group.Id}: {group.Status} [{string.Join(",", group.SelectedOptionIds)}]");
}
File.WriteAllText("page.json", result.ToJson());Recognize also accepts a Stream or a SkiaSharp SKBitmap. Pass sourcePageIndex when the image came from a PDF page.
| Option | Default | Role |
|---|---|---|
FilledThreshold |
0.70 |
Fill score at or above this counts as filled |
BlankThreshold |
0.20 |
Fill score at or below this counts as empty |
AutoRotate |
true |
Try 0° / 90° / 180° / 270° using anchors |
Deskew |
true |
Small-angle deskew |
MinAlignmentScore |
0.35 |
Below this, the page fails (no silent answers) |
TreatMultipleAsReview |
true |
Multiple groups set page NeedsReview |
TreatAmbiguousAsReview |
true |
Ambiguous groups set page NeedsReview |
MinEffectiveDpi |
150 |
Warn/fail when estimated DPI is too low |
Require 0 ≤ BlankThreshold < FilledThreshold ≤ 1.
Page: Succeeded, NeedsReview, Failed, Skipped
Group: Selected, Blank, Multiple, Ambiguous, Unreadable
The engine never picks a winner when two bubbles are filled or a mark is in the ambiguous band. Light/erased marks show as Partial or ErasureSuspect on the option, and the group becomes Ambiguous (not a silent Selected).
Reference Omr.Engine and Omr.Pdf. PDFs must be scanned sheets (each page contains an embedded raster). Vector-only pages cannot be rasterized in this build.
using Omr.Engine;
using Omr.Engine.Templates;
using Omr.Pdf;
OmrTemplate template = OmrTemplate.Load("exam-cs101-v3.json");
using var engine = new OmrProcessor();
using var pdf = new PdfOmrProcessor(engine);
await foreach (var page in pdf.RecognizeAsync(
"exams.pdf",
templateResolver: new MyTemplateResolver(template),
barcodeDecoder: new MyQrDecoder(), // optional; implement IBarcodeDecoder
options: new PdfOmrOptions
{
RequireQr = true,
FallbackTemplate = template,
StartPageIndex = 0,
FailFast = false
}))
{
// Grade and store using page.Groups — not the engine
}PdfOmrOptions |
Default | Role |
|---|---|---|
RequireQr |
true |
Fail the page if QR decode returns nothing |
FallbackTemplate |
null |
Template when the resolver returns null |
StartPageIndex / EndPageIndexInclusive |
all pages | Page range (0-based) |
FailFast |
false |
Stop the batch on the first failed page |
Implement ITemplateResolver.Resolve(qrValue) to map a QR string to a template. The sample resolver (CatalogTemplateResolver) understands templateId|version|studentId.
Implement IBarcodeDecoder yourself (the POC uses ZXing). The engine does not depend on a barcode library.
Coordinates are normalized [0, 1] with origin at the top-left, Y down. Use at least three anchor regions (four corners work well); make one fiducial larger so 180° rotation can be distinguished.
{
"schemaVersion": "1.0",
"templateId": "exam-cs101",
"templateVersion": "3",
"page": { "width": 8.5, "height": 11.0, "unit": "inch", "orientation": "portrait" },
"regions": [
{ "id": "tl-anchor", "kind": "anchor", "rect": { "x": 0.035, "y": 0.032, "w": 0.055, "h": 0.042 } },
{ "id": "qr", "kind": "barcode", "rect": { "x": 0.70, "y": 0.035, "w": 0.22, "h": 0.12 } }
],
"groups": [
{
"id": "q1",
"groupKind": "answer",
"linkedGroupId": "q1-confidence",
"selectionPolicy": "single",
"marks": [
{ "id": "A", "shape": "oval", "rect": { "x": 0.16, "y": 0.24, "w": 0.045, "h": 0.028 } }
]
}
]
}kind:anchor|barcode|mark(marks usually live undergroups)selectionPolicy:singleormultiplegroupKind:answer|confidence|other(hints only; scoring is the same)shape:oval|circle|square|rectangle
Load and check before a batch: OmrTemplate.Load(path) then EnsureValid() / Validate().
Print registration marks on the physical sheet in the same places as the anchor rects.
The POC keeps grading out of the engine:
IExamGrader/WeightedConfidenceGrader— example: correct + High=1.0, Medium=0.5, Low=0.25; blank/multiple/ambiguous score 0 and flag reviewIResultDelivery/FolderDelivery— writessummary.csv(swap this for email or a test mailbox)GradeAnnotator— draws OK / X / ? on a copy of the page
Copy those types into your app or replace them.
Use clean 150–300 DPI scans, full page visible, dark filled bubbles, and printed corner anchors. Crops that clip anchors, extreme skew, or faint marks produce NeedsReview or Failed with a reason instead of a guessed answer.
- No handwriting / OCR
- No exam layout generator
- No production email or student directory
- Scanned PDF pages must contain an embedded image; Pdfium page rendering is not included (it crashed on some Linux hosts)
- Console POC only (not a WinForms UI)
This repository is under the Unlicense. Syncfusion and other NuGet dependencies have their own licenses.