|
| 1 | +# OMR Sample |
| 2 | + |
| 3 | +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. |
| 4 | + |
| 5 | +## What you get |
| 6 | + |
| 7 | +| Project / Folder | Use it for | |
| 8 | +| --- | --- | |
| 9 | +| [lib](lib) | Pre-compiled Release assemblies for `Omr.Engine` | |
| 10 | +| [src/Omr.Pdf](src/Omr.Pdf) | Run the engine over a multi-page **scanned** PDF | |
| 11 | +| [src/Omr.Poc](src/Omr.Poc) | End-to-end console sample: generate a PDF, decode QR, grade, annotate, write CSV | |
| 12 | +| [samples/templates/exam-cs101-v3.json](samples/templates/exam-cs101-v3.json) | Example exam template | |
| 13 | + |
| 14 | +The engine does **not** grade papers, send email, or require WinForms. The POC demonstrates those as replaceable callbacks. |
| 15 | + |
| 16 | +## Requirements |
| 17 | + |
| 18 | +- [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0) |
| 19 | +- Network access to restore NuGet packages (SkiaSharp, Syncfusion PDF, ZXing for the POC) |
| 20 | +- Optional: `SYNCFUSION_LICENSE` — without a key, Syncfusion may watermark generated PDFs |
| 21 | + |
| 22 | +Linux CI/dev images already use `SkiaSharp.NativeAssets.Linux.NoDependencies`. |
| 23 | + |
| 24 | +## Build |
| 25 | + |
| 26 | +```bash |
| 27 | +dotnet build Omr.sln |
| 28 | +``` |
| 29 | + |
| 30 | +## Run the sample (POC) |
| 31 | + |
| 32 | +The console app generates a synthetic multi-page exam PDF, recognizes each page, applies a sample grading formula, and writes outputs to a folder. |
| 33 | + |
| 34 | +```bash |
| 35 | +dotnet run --project src/Omr.Poc -- --pages 4 --out ./poc-output |
| 36 | +``` |
| 37 | + |
| 38 | +| Argument | Default | Meaning | |
| 39 | +| --- | --- | --- | |
| 40 | +| `--pages` | `3` | Number of answer sheets to generate and process | |
| 41 | +| `--out` | `./poc-output` | Output directory | |
| 42 | + |
| 43 | +**Outputs** |
| 44 | + |
| 45 | +| File | Contents | |
| 46 | +| --- | --- | |
| 47 | +| `exam-batch.pdf` | Generated input batch | |
| 48 | +| `page-NNN.json` | Recognition result for that page | |
| 49 | +| `page-NNN.png` | Annotated correction image | |
| 50 | +| `annotated.pdf` | All annotated pages | |
| 51 | +| `summary.csv` | Student id, grade, answers, review flag, paths | |
| 52 | + |
| 53 | +QR payloads in the sample look like `exam-cs101|v3|student-001`. Ctrl+C cancels a run. |
| 54 | + |
| 55 | +Page variants in the sample: all correct, one wrong answer, one blank, one double-mark (needs review). |
| 56 | + |
| 57 | +## Use the engine on an image |
| 58 | + |
| 59 | +Reference `lib/Omr.Engine.dll` from your app. |
| 60 | + |
| 61 | +```csharp |
| 62 | +using Omr.Engine; |
| 63 | +using Omr.Engine.Templates; |
| 64 | + |
| 65 | +OmrTemplate template = OmrTemplate.Load("exam-cs101-v3.json"); |
| 66 | +template.EnsureValid(); |
| 67 | + |
| 68 | +var options = new OmrRecognitionOptions |
| 69 | +{ |
| 70 | + FilledThreshold = 0.70f, |
| 71 | + BlankThreshold = 0.20f, |
| 72 | + AutoRotate = true, |
| 73 | + Deskew = true, |
| 74 | + MinAlignmentScore = 0.35f |
| 75 | +}; |
| 76 | + |
| 77 | +using var processor = new OmrProcessor(options); |
| 78 | +Omr.Engine.Results.OmrPageResult result = processor.Recognize("scan.png", template); |
| 79 | + |
| 80 | +Console.WriteLine($"{result.PageStatus} align={result.AlignmentScore:0.00}"); |
| 81 | + |
| 82 | +foreach (var group in result.Groups) |
| 83 | +{ |
| 84 | + Console.WriteLine($"{group.Id}: {group.Status} [{string.Join(",", group.SelectedOptionIds)}]"); |
| 85 | +} |
| 86 | + |
| 87 | +File.WriteAllText("page.json", result.ToJson()); |
| 88 | +``` |
| 89 | + |
| 90 | +`Recognize` also accepts a `Stream` or a SkiaSharp `SKBitmap`. Pass `sourcePageIndex` when the image came from a PDF page. |
| 91 | + |
| 92 | +### Recognition options |
| 93 | + |
| 94 | +| Option | Default | Role | |
| 95 | +| --- | --- | --- | |
| 96 | +| `FilledThreshold` | `0.70` | Fill score at or above this counts as filled | |
| 97 | +| `BlankThreshold` | `0.20` | Fill score at or below this counts as empty | |
| 98 | +| `AutoRotate` | `true` | Try 0° / 90° / 180° / 270° using anchors | |
| 99 | +| `Deskew` | `true` | Small-angle deskew | |
| 100 | +| `MinAlignmentScore` | `0.35` | Below this, the page fails (no silent answers) | |
| 101 | +| `TreatMultipleAsReview` | `true` | `Multiple` groups set page `NeedsReview` | |
| 102 | +| `TreatAmbiguousAsReview` | `true` | `Ambiguous` groups set page `NeedsReview` | |
| 103 | +| `MinEffectiveDpi` | `150` | Warn/fail when estimated DPI is too low | |
| 104 | + |
| 105 | +Require `0 ≤ BlankThreshold < FilledThreshold ≤ 1`. |
| 106 | + |
| 107 | +### Result statuses |
| 108 | + |
| 109 | +**Page:** `Succeeded`, `NeedsReview`, `Failed`, `Skipped` |
| 110 | + |
| 111 | +**Group:** `Selected`, `Blank`, `Multiple`, `Ambiguous`, `Unreadable` |
| 112 | + |
| 113 | +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`). |
| 114 | + |
| 115 | +## Use the PDF adapter |
| 116 | + |
| 117 | +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. |
| 118 | + |
| 119 | +```csharp |
| 120 | +using Omr.Engine; |
| 121 | +using Omr.Engine.Templates; |
| 122 | +using Omr.Pdf; |
| 123 | + |
| 124 | +OmrTemplate template = OmrTemplate.Load("exam-cs101-v3.json"); |
| 125 | + |
| 126 | +using var engine = new OmrProcessor(); |
| 127 | +using var pdf = new PdfOmrProcessor(engine); |
| 128 | + |
| 129 | +await foreach (var page in pdf.RecognizeAsync( |
| 130 | + "exams.pdf", |
| 131 | + templateResolver: new MyTemplateResolver(template), |
| 132 | + barcodeDecoder: new MyQrDecoder(), // optional; implement IBarcodeDecoder |
| 133 | + options: new PdfOmrOptions |
| 134 | + { |
| 135 | + RequireQr = true, |
| 136 | + FallbackTemplate = template, |
| 137 | + StartPageIndex = 0, |
| 138 | + FailFast = false |
| 139 | + })) |
| 140 | +{ |
| 141 | + // Grade and store using page.Groups — not the engine |
| 142 | +} |
| 143 | +``` |
| 144 | + |
| 145 | +| `PdfOmrOptions` | Default | Role | |
| 146 | +| --- | --- | --- | |
| 147 | +| `RequireQr` | `true` | Fail the page if QR decode returns nothing | |
| 148 | +| `FallbackTemplate` | `null` | Template when the resolver returns null | |
| 149 | +| `StartPageIndex` / `EndPageIndexInclusive` | all pages | Page range (0-based) | |
| 150 | +| `FailFast` | `false` | Stop the batch on the first failed page | |
| 151 | + |
| 152 | +Implement `ITemplateResolver.Resolve(qrValue)` to map a QR string to a template. The sample resolver (`CatalogTemplateResolver`) understands `templateId|version|studentId`. |
| 153 | + |
| 154 | +Implement `IBarcodeDecoder` yourself (the POC uses ZXing). The engine does not depend on a barcode library. |
| 155 | + |
| 156 | +## Define a template |
| 157 | + |
| 158 | +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. |
| 159 | + |
| 160 | +```json |
| 161 | +{ |
| 162 | + "schemaVersion": "1.0", |
| 163 | + "templateId": "exam-cs101", |
| 164 | + "templateVersion": "3", |
| 165 | + "page": { "width": 8.5, "height": 11.0, "unit": "inch", "orientation": "portrait" }, |
| 166 | + "regions": [ |
| 167 | + { "id": "tl-anchor", "kind": "anchor", "rect": { "x": 0.035, "y": 0.032, "w": 0.055, "h": 0.042 } }, |
| 168 | + { "id": "qr", "kind": "barcode", "rect": { "x": 0.70, "y": 0.035, "w": 0.22, "h": 0.12 } } |
| 169 | + ], |
| 170 | + "groups": [ |
| 171 | + { |
| 172 | + "id": "q1", |
| 173 | + "groupKind": "answer", |
| 174 | + "linkedGroupId": "q1-confidence", |
| 175 | + "selectionPolicy": "single", |
| 176 | + "marks": [ |
| 177 | + { "id": "A", "shape": "oval", "rect": { "x": 0.16, "y": 0.24, "w": 0.045, "h": 0.028 } } |
| 178 | + ] |
| 179 | + } |
| 180 | + ] |
| 181 | +} |
| 182 | +``` |
| 183 | + |
| 184 | +- `kind`: `anchor` | `barcode` | `mark` (marks usually live under `groups`) |
| 185 | +- `selectionPolicy`: `single` or `multiple` |
| 186 | +- `groupKind`: `answer` | `confidence` | `other` (hints only; scoring is the same) |
| 187 | +- `shape`: `oval` | `circle` | `square` | `rectangle` |
| 188 | + |
| 189 | +Load and check before a batch: `OmrTemplate.Load(path)` then `EnsureValid()` / `Validate()`. |
| 190 | + |
| 191 | +Print registration marks on the physical sheet in the same places as the `anchor` rects. |
| 192 | + |
| 193 | +## Plug in grading and delivery (POC) |
| 194 | + |
| 195 | +The POC keeps grading out of the engine: |
| 196 | + |
| 197 | +- `IExamGrader` / `WeightedConfidenceGrader` — example: correct + High=1.0, Medium=0.5, Low=0.25; blank/multiple/ambiguous score 0 and flag review |
| 198 | +- `IResultDelivery` / `FolderDelivery` — writes `summary.csv` (swap this for email or a test mailbox) |
| 199 | +- `GradeAnnotator` — draws OK / X / ? on a copy of the page |
| 200 | + |
| 201 | +Copy those types into your app or replace them. |
| 202 | + |
| 203 | +## Scan quality |
| 204 | + |
| 205 | +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. |
| 206 | + |
| 207 | +## Limits |
| 208 | + |
| 209 | +- No handwriting / OCR |
| 210 | +- No exam layout generator |
| 211 | +- No production email or student directory |
| 212 | +- Scanned PDF pages must contain an embedded image; Pdfium page rendering is not included (it crashed on some Linux hosts) |
| 213 | +- Console POC only (not a WinForms UI) |
| 214 | + |
| 215 | +## License |
| 216 | + |
| 217 | +This repository is under the [Unlicense](LICENSE). Syncfusion and other NuGet dependencies have their own licenses. |
0 commit comments