diff --git a/OMR/.NET Core/OMR/.gitignore b/OMR/.NET Core/OMR/.gitignore
new file mode 100644
index 00000000..af6adb19
--- /dev/null
+++ b/OMR/.NET Core/OMR/.gitignore
@@ -0,0 +1,10 @@
+bin/
+obj/
+.vs/
+.idea/
+*.user
+*.suo
+TestResults/
+artifacts/
+poc-output/
+*.nupkg
diff --git a/OMR/.NET Core/OMR/Directory.Build.props b/OMR/.NET Core/OMR/Directory.Build.props
new file mode 100644
index 00000000..103bae81
--- /dev/null
+++ b/OMR/.NET Core/OMR/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+ net9.0
+ enable
+ enable
+ latest
+ latest
+
+
diff --git a/OMR/.NET Core/OMR/LICENSE b/OMR/.NET Core/OMR/LICENSE
new file mode 100644
index 00000000..fdddb29a
--- /dev/null
+++ b/OMR/.NET Core/OMR/LICENSE
@@ -0,0 +1,24 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to
diff --git a/OMR/.NET Core/OMR/Omr.sln b/OMR/.NET Core/OMR/Omr.sln
new file mode 100644
index 00000000..1556f975
--- /dev/null
+++ b/OMR/.NET Core/OMR/Omr.sln
@@ -0,0 +1,54 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Omr.Pdf", "src\Omr.Pdf\Omr.Pdf.csproj", "{F5EFFC8A-9899-4405-AF55-C8538FBD5024}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Omr.Poc", "src\Omr.Poc\Omr.Poc.csproj", "{67082E98-A89E-4539-A229-57A8A21F42C6}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Debug|x64.Build.0 = Debug|Any CPU
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Debug|x86.Build.0 = Debug|Any CPU
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Release|Any CPU.Build.0 = Release|Any CPU
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Release|x64.ActiveCfg = Release|Any CPU
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Release|x64.Build.0 = Release|Any CPU
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Release|x86.ActiveCfg = Release|Any CPU
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024}.Release|x86.Build.0 = Release|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Debug|x64.Build.0 = Debug|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Debug|x86.Build.0 = Debug|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Release|x64.ActiveCfg = Release|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Release|x64.Build.0 = Release|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Release|x86.ActiveCfg = Release|Any CPU
+ {67082E98-A89E-4539-A229-57A8A21F42C6}.Release|x86.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(NestedProjects) = preSolution
+ {F5EFFC8A-9899-4405-AF55-C8538FBD5024} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {67082E98-A89E-4539-A229-57A8A21F42C6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ EndGlobalSection
+EndGlobal
diff --git a/OMR/.NET Core/OMR/README.md b/OMR/.NET Core/OMR/README.md
new file mode 100644
index 00000000..40aa868d
--- /dev/null
+++ b/OMR/.NET Core/OMR/README.md
@@ -0,0 +1,217 @@
+# OMR Sample
+
+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.
+
+## What you get
+
+| Project / Folder | Use it for |
+| --- | --- |
+| [lib](lib) | Pre-compiled Release assemblies for `Omr.Engine` |
+| [src/Omr.Pdf](src/Omr.Pdf) | Run the engine over a multi-page **scanned** PDF |
+| [src/Omr.Poc](src/Omr.Poc) | End-to-end console sample: generate a PDF, decode QR, grade, annotate, write CSV |
+| [samples/templates/exam-cs101-v3.json](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.
+
+## Requirements
+
+- [.NET 9 SDK](https://dotnet.microsoft.com/download/dotnet/9.0)
+- 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`.
+
+## Build
+
+```bash
+dotnet build Omr.sln
+```
+
+## Run the sample (POC)
+
+The console app generates a synthetic multi-page exam PDF, recognizes each page, applies a sample grading formula, and writes outputs to a folder.
+
+```bash
+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).
+
+## Use the engine on an image
+
+Reference `lib/Omr.Engine.dll` from your app.
+
+```csharp
+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.
+
+### Recognition options
+
+| 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`.
+
+### Result statuses
+
+**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`).
+
+## Use the PDF adapter
+
+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.
+
+```csharp
+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.
+
+## Define a template
+
+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.
+
+```json
+{
+ "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 under `groups`)
+- `selectionPolicy`: `single` or `multiple`
+- `groupKind`: `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.
+
+## Plug in grading and delivery (POC)
+
+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 review
+- `IResultDelivery` / `FolderDelivery` — writes `summary.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.
+
+## Scan quality
+
+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.
+
+## Limits
+
+- 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)
+
+## License
+
+This repository is under the [Unlicense](LICENSE). Syncfusion and other NuGet dependencies have their own licenses.
diff --git a/OMR/.NET Core/OMR/lib/Omr.Engine.deps.json b/OMR/.NET Core/OMR/lib/Omr.Engine.deps.json
new file mode 100644
index 00000000..7a57aa9b
--- /dev/null
+++ b/OMR/.NET Core/OMR/lib/Omr.Engine.deps.json
@@ -0,0 +1,154 @@
+{
+ "runtimeTarget": {
+ "name": ".NETCoreApp,Version=v9.0",
+ "signature": ""
+ },
+ "compilationOptions": {},
+ "targets": {
+ ".NETCoreApp,Version=v9.0": {
+ "Omr.Engine/1.0.0": {
+ "dependencies": {
+ "SkiaSharp": "3.119.1",
+ "SkiaSharp.NativeAssets.Linux.NoDependencies": "3.119.1"
+ },
+ "runtime": {
+ "Omr.Engine.dll": {}
+ }
+ },
+ "SkiaSharp/3.119.1": {
+ "dependencies": {
+ "SkiaSharp.NativeAssets.Win32": "3.119.1",
+ "SkiaSharp.NativeAssets.macOS": "3.119.1"
+ },
+ "runtime": {
+ "lib/net8.0/SkiaSharp.dll": {
+ "assemblyVersion": "3.119.0.0",
+ "fileVersion": "3.119.1.0"
+ }
+ }
+ },
+ "SkiaSharp.NativeAssets.Linux.NoDependencies/3.119.1": {
+ "runtimeTargets": {
+ "runtimes/linux-arm/native/libSkiaSharp.so": {
+ "rid": "linux-arm",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/linux-arm64/native/libSkiaSharp.so": {
+ "rid": "linux-arm64",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/linux-loongarch64/native/libSkiaSharp.so": {
+ "rid": "linux-loongarch64",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/linux-musl-arm/native/libSkiaSharp.so": {
+ "rid": "linux-musl-arm",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/linux-musl-arm64/native/libSkiaSharp.so": {
+ "rid": "linux-musl-arm64",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/linux-musl-loongarch64/native/libSkiaSharp.so": {
+ "rid": "linux-musl-loongarch64",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/linux-musl-riscv64/native/libSkiaSharp.so": {
+ "rid": "linux-musl-riscv64",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/linux-musl-x64/native/libSkiaSharp.so": {
+ "rid": "linux-musl-x64",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/linux-riscv64/native/libSkiaSharp.so": {
+ "rid": "linux-riscv64",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/linux-x64/native/libSkiaSharp.so": {
+ "rid": "linux-x64",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/linux-x86/native/libSkiaSharp.so": {
+ "rid": "linux-x86",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ }
+ }
+ },
+ "SkiaSharp.NativeAssets.macOS/3.119.1": {
+ "runtimeTargets": {
+ "runtimes/osx/native/libSkiaSharp.dylib": {
+ "rid": "osx",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ }
+ }
+ },
+ "SkiaSharp.NativeAssets.Win32/3.119.1": {
+ "runtimeTargets": {
+ "runtimes/win-arm64/native/libSkiaSharp.dll": {
+ "rid": "win-arm64",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/win-x64/native/libSkiaSharp.dll": {
+ "rid": "win-x64",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ },
+ "runtimes/win-x86/native/libSkiaSharp.dll": {
+ "rid": "win-x86",
+ "assetType": "native",
+ "fileVersion": "0.0.0.0"
+ }
+ }
+ }
+ }
+ },
+ "libraries": {
+ "Omr.Engine/1.0.0": {
+ "type": "project",
+ "serviceable": false,
+ "sha512": ""
+ },
+ "SkiaSharp/3.119.1": {
+ "type": "package",
+ "serviceable": true,
+ "sha512": "sha512-+Ru1BTSZQne3Vp+vbSb50Ke3Nlc3ZnItxx4+751J9WZ8YzLKAV/n+9DAo4zFTyeCI//ueT63c+VybmTTpYBEiw==",
+ "path": "skiasharp/3.119.1",
+ "hashPath": "skiasharp.3.119.1.nupkg.sha512"
+ },
+ "SkiaSharp.NativeAssets.Linux.NoDependencies/3.119.1": {
+ "type": "package",
+ "serviceable": true,
+ "sha512": "sha512-5jj6gBCe4K6TLnIERBXZfmUNE0TUyeTaF28RCxdCgIDVDaCAbHwEbk99eNYRFem/ubW/hKdvayZBOt6PaDL05g==",
+ "path": "skiasharp.nativeassets.linux.nodependencies/3.119.1",
+ "hashPath": "skiasharp.nativeassets.linux.nodependencies.3.119.1.nupkg.sha512"
+ },
+ "SkiaSharp.NativeAssets.macOS/3.119.1": {
+ "type": "package",
+ "serviceable": true,
+ "sha512": "sha512-6hR3BdLhApjDxR1bFrJ7/lMydPfI01s3K+3WjIXFUlfC0MFCFCwRzv+JtzIkW9bDXs7XUVQS+6EVf0uzCasnGQ==",
+ "path": "skiasharp.nativeassets.macos/3.119.1",
+ "hashPath": "skiasharp.nativeassets.macos.3.119.1.nupkg.sha512"
+ },
+ "SkiaSharp.NativeAssets.Win32/3.119.1": {
+ "type": "package",
+ "serviceable": true,
+ "sha512": "sha512-8C4GSXVJqSr0y3Tyyv5jz6MJSTVUyYkMjeKrzK+VyZPGLo89MNoUEclVuYahzOCDdtbfXrd2HtxXfDuvoSXrUw==",
+ "path": "skiasharp.nativeassets.win32/3.119.1",
+ "hashPath": "skiasharp.nativeassets.win32.3.119.1.nupkg.sha512"
+ }
+ }
+}
\ No newline at end of file
diff --git a/OMR/.NET Core/OMR/lib/Omr.Engine.dll b/OMR/.NET Core/OMR/lib/Omr.Engine.dll
new file mode 100644
index 00000000..6bcd17fd
Binary files /dev/null and b/OMR/.NET Core/OMR/lib/Omr.Engine.dll differ
diff --git a/OMR/.NET Core/OMR/lib/Omr.Engine.pdb b/OMR/.NET Core/OMR/lib/Omr.Engine.pdb
new file mode 100644
index 00000000..a2fc993d
Binary files /dev/null and b/OMR/.NET Core/OMR/lib/Omr.Engine.pdb differ
diff --git a/OMR/.NET Core/OMR/samples/templates/exam-cs101-v3.json b/OMR/.NET Core/OMR/samples/templates/exam-cs101-v3.json
new file mode 100644
index 00000000..8a790f23
--- /dev/null
+++ b/OMR/.NET Core/OMR/samples/templates/exam-cs101-v3.json
@@ -0,0 +1,130 @@
+{
+ "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": "tr-anchor", "kind": "anchor", "rect": { "x": 0.93, "y": 0.04, "w": 0.03, "h": 0.023 } },
+ { "id": "bl-anchor", "kind": "anchor", "rect": { "x": 0.04, "y": 0.94, "w": 0.03, "h": 0.023 } },
+ { "id": "br-anchor", "kind": "anchor", "rect": { "x": 0.93, "y": 0.94, "w": 0.03, "h": 0.023 } },
+ { "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 } },
+ { "id": "B", "shape": "oval", "rect": { "x": 0.28, "y": 0.24, "w": 0.045, "h": 0.028 } },
+ { "id": "C", "shape": "oval", "rect": { "x": 0.40, "y": 0.24, "w": 0.045, "h": 0.028 } },
+ { "id": "D", "shape": "oval", "rect": { "x": 0.52, "y": 0.24, "w": 0.045, "h": 0.028 } }
+ ]
+ },
+ {
+ "id": "q1-confidence",
+ "groupKind": "confidence",
+ "selectionPolicy": "single",
+ "marks": [
+ { "id": "H", "shape": "oval", "rect": { "x": 0.68, "y": 0.24, "w": 0.045, "h": 0.028 } },
+ { "id": "M", "shape": "oval", "rect": { "x": 0.78, "y": 0.24, "w": 0.045, "h": 0.028 } },
+ { "id": "L", "shape": "oval", "rect": { "x": 0.88, "y": 0.24, "w": 0.045, "h": 0.028 } }
+ ]
+ },
+ {
+ "id": "q2",
+ "groupKind": "answer",
+ "linkedGroupId": "q2-confidence",
+ "selectionPolicy": "single",
+ "marks": [
+ { "id": "A", "shape": "oval", "rect": { "x": 0.16, "y": 0.36, "w": 0.045, "h": 0.028 } },
+ { "id": "B", "shape": "oval", "rect": { "x": 0.28, "y": 0.36, "w": 0.045, "h": 0.028 } },
+ { "id": "C", "shape": "oval", "rect": { "x": 0.40, "y": 0.36, "w": 0.045, "h": 0.028 } },
+ { "id": "D", "shape": "oval", "rect": { "x": 0.52, "y": 0.36, "w": 0.045, "h": 0.028 } }
+ ]
+ },
+ {
+ "id": "q2-confidence",
+ "groupKind": "confidence",
+ "selectionPolicy": "single",
+ "marks": [
+ { "id": "H", "shape": "oval", "rect": { "x": 0.68, "y": 0.36, "w": 0.045, "h": 0.028 } },
+ { "id": "M", "shape": "oval", "rect": { "x": 0.78, "y": 0.36, "w": 0.045, "h": 0.028 } },
+ { "id": "L", "shape": "oval", "rect": { "x": 0.88, "y": 0.36, "w": 0.045, "h": 0.028 } }
+ ]
+ },
+ {
+ "id": "q3",
+ "groupKind": "answer",
+ "linkedGroupId": "q3-confidence",
+ "selectionPolicy": "single",
+ "marks": [
+ { "id": "A", "shape": "oval", "rect": { "x": 0.16, "y": 0.48, "w": 0.045, "h": 0.028 } },
+ { "id": "B", "shape": "oval", "rect": { "x": 0.28, "y": 0.48, "w": 0.045, "h": 0.028 } },
+ { "id": "C", "shape": "oval", "rect": { "x": 0.40, "y": 0.48, "w": 0.045, "h": 0.028 } },
+ { "id": "D", "shape": "oval", "rect": { "x": 0.52, "y": 0.48, "w": 0.045, "h": 0.028 } }
+ ]
+ },
+ {
+ "id": "q3-confidence",
+ "groupKind": "confidence",
+ "selectionPolicy": "single",
+ "marks": [
+ { "id": "H", "shape": "oval", "rect": { "x": 0.68, "y": 0.48, "w": 0.045, "h": 0.028 } },
+ { "id": "M", "shape": "oval", "rect": { "x": 0.78, "y": 0.48, "w": 0.045, "h": 0.028 } },
+ { "id": "L", "shape": "oval", "rect": { "x": 0.88, "y": 0.48, "w": 0.045, "h": 0.028 } }
+ ]
+ },
+ {
+ "id": "q4",
+ "groupKind": "answer",
+ "linkedGroupId": "q4-confidence",
+ "selectionPolicy": "single",
+ "marks": [
+ { "id": "A", "shape": "oval", "rect": { "x": 0.16, "y": 0.60, "w": 0.045, "h": 0.028 } },
+ { "id": "B", "shape": "oval", "rect": { "x": 0.28, "y": 0.60, "w": 0.045, "h": 0.028 } },
+ { "id": "C", "shape": "oval", "rect": { "x": 0.40, "y": 0.60, "w": 0.045, "h": 0.028 } },
+ { "id": "D", "shape": "oval", "rect": { "x": 0.52, "y": 0.60, "w": 0.045, "h": 0.028 } }
+ ]
+ },
+ {
+ "id": "q4-confidence",
+ "groupKind": "confidence",
+ "selectionPolicy": "single",
+ "marks": [
+ { "id": "H", "shape": "oval", "rect": { "x": 0.68, "y": 0.60, "w": 0.045, "h": 0.028 } },
+ { "id": "M", "shape": "oval", "rect": { "x": 0.78, "y": 0.60, "w": 0.045, "h": 0.028 } },
+ { "id": "L", "shape": "oval", "rect": { "x": 0.88, "y": 0.60, "w": 0.045, "h": 0.028 } }
+ ]
+ },
+ {
+ "id": "q5",
+ "groupKind": "answer",
+ "linkedGroupId": "q5-confidence",
+ "selectionPolicy": "single",
+ "marks": [
+ { "id": "A", "shape": "oval", "rect": { "x": 0.16, "y": 0.72, "w": 0.045, "h": 0.028 } },
+ { "id": "B", "shape": "oval", "rect": { "x": 0.28, "y": 0.72, "w": 0.045, "h": 0.028 } },
+ { "id": "C", "shape": "oval", "rect": { "x": 0.40, "y": 0.72, "w": 0.045, "h": 0.028 } },
+ { "id": "D", "shape": "oval", "rect": { "x": 0.52, "y": 0.72, "w": 0.045, "h": 0.028 } }
+ ]
+ },
+ {
+ "id": "q5-confidence",
+ "groupKind": "confidence",
+ "selectionPolicy": "single",
+ "marks": [
+ { "id": "H", "shape": "oval", "rect": { "x": 0.68, "y": 0.72, "w": 0.045, "h": 0.028 } },
+ { "id": "M", "shape": "oval", "rect": { "x": 0.78, "y": 0.72, "w": 0.045, "h": 0.028 } },
+ { "id": "L", "shape": "oval", "rect": { "x": 0.88, "y": 0.72, "w": 0.045, "h": 0.028 } }
+ ]
+ }
+ ]
+}
diff --git a/OMR/.NET Core/OMR/src/Omr.Pdf/Omr.Pdf.csproj b/OMR/.NET Core/OMR/src/Omr.Pdf/Omr.Pdf.csproj
new file mode 100644
index 00000000..cb3ed4ca
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Pdf/Omr.Pdf.csproj
@@ -0,0 +1,22 @@
+
+
+
+ net9.0
+ Syncfusion PDF adapter for the OMR engine.
+ Omr.Pdf
+
+
+
+
+
+
+
+
+
+
+
+ ..\..\lib\Omr.Engine.dll
+
+
+
+
diff --git a/OMR/.NET Core/OMR/src/Omr.Pdf/PdfOmrOptions.cs b/OMR/.NET Core/OMR/src/Omr.Pdf/PdfOmrOptions.cs
new file mode 100644
index 00000000..fb9380de
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Pdf/PdfOmrOptions.cs
@@ -0,0 +1,26 @@
+using Omr.Engine.Templates;
+
+namespace Omr.Pdf;
+
+public sealed class PdfOmrOptions
+{
+ public float Dpi { get; init; } = 200f;
+
+ public int? StartPageIndex { get; init; }
+
+ public int? EndPageIndexInclusive { get; init; }
+
+ public bool FailFast { get; init; }
+
+ public bool RequireQr { get; init; } = true;
+
+ public bool SkipAnnotations { get; init; }
+
+ ///
+ /// Reserved. Pdfium page rasterization is not loaded in this build (it can crash some Linux hosts).
+ /// Scanned sheets are rasterized by extracting the largest embedded page image.
+ ///
+ public bool UsePdfiumRasterizer { get; init; }
+
+ public OmrTemplate? FallbackTemplate { get; init; }
+}
diff --git a/OMR/.NET Core/OMR/src/Omr.Pdf/PdfOmrProcessor.cs b/OMR/.NET Core/OMR/src/Omr.Pdf/PdfOmrProcessor.cs
new file mode 100644
index 00000000..ebfcd9c4
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Pdf/PdfOmrProcessor.cs
@@ -0,0 +1,220 @@
+using System.Runtime.CompilerServices;
+using Omr.Engine;
+using Omr.Engine.Geometry;
+using Omr.Engine.Results;
+using Omr.Engine.Templates;
+using SkiaSharp;
+using Syncfusion.Pdf;
+using Syncfusion.Pdf.Parsing;
+
+namespace Omr.Pdf;
+
+public sealed class PdfOmrProcessor : IDisposable
+{
+ private readonly OmrProcessor _engine;
+ private bool _disposed;
+
+ public PdfOmrProcessor(OmrProcessor? engine = null)
+ {
+ _engine = engine ?? new OmrProcessor();
+ OwnsEngine = engine is null;
+ }
+
+ private bool OwnsEngine { get; }
+
+ public async IAsyncEnumerable RecognizeAsync(
+ string pdfPath,
+ ITemplateResolver templateResolver,
+ IBarcodeDecoder? barcodeDecoder = null,
+ PdfOmrOptions? options = null,
+ IProgress? progress = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await using FileStream stream = new(pdfPath, FileMode.Open, FileAccess.Read, FileShare.Read);
+ await foreach (OmrPageResult page in RecognizeAsync(stream, templateResolver, barcodeDecoder, options, progress, cancellationToken))
+ {
+ yield return page;
+ }
+ }
+
+ public async IAsyncEnumerable RecognizeAsync(
+ Stream pdfStream,
+ ITemplateResolver templateResolver,
+ IBarcodeDecoder? barcodeDecoder = null,
+ PdfOmrOptions? options = null,
+ IProgress? progress = null,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ PdfOmrOptions pdfOptions = options ?? new PdfOmrOptions();
+
+ PdfLoadedDocument? document = TryLoadDocument(pdfStream, out OmrPageResult? loadFailure);
+ if (document is null)
+ {
+ yield return loadFailure!;
+ yield break;
+ }
+
+ using (document)
+ {
+ int pageCount = document.Pages.Count;
+ if (pageCount <= 0)
+ {
+ yield return OmrPageResult.Failed(0, WorkflowFailureReasons.PdfUnsupported, "The PDF has no pages.");
+ yield break;
+ }
+
+ int start = pdfOptions.StartPageIndex ?? 0;
+ int end = pdfOptions.EndPageIndexInclusive ?? (pageCount - 1);
+ start = Math.Clamp(start, 0, pageCount - 1);
+ end = Math.Clamp(end, start, pageCount - 1);
+
+ int completed = 0;
+ for (int pageIndex = start; pageIndex <= end; pageIndex++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ OmrPageResult result = ProcessPage(
+ document,
+ pageIndex,
+ templateResolver,
+ barcodeDecoder,
+ pdfOptions);
+
+ completed++;
+ progress?.Report(new OmrProgress
+ {
+ PageIndex = pageIndex,
+ PagesCompleted = completed,
+ Status = result.PageStatus
+ });
+
+ yield return result;
+ await Task.Yield();
+
+ if (pdfOptions.FailFast && result.PageStatus == PageStatus.Failed)
+ {
+ yield break;
+ }
+ }
+ }
+ }
+
+ private OmrPageResult ProcessPage(
+ PdfLoadedDocument document,
+ int pageIndex,
+ ITemplateResolver templateResolver,
+ IBarcodeDecoder? barcodeDecoder,
+ PdfOmrOptions pdfOptions)
+ {
+ SKBitmap bitmap;
+ try
+ {
+ bitmap = PdfPageRasterizer.Rasterize(document, pageIndex, pdfOptions);
+ }
+ catch (Exception ex)
+ {
+ return OmrPageResult.Failed(pageIndex, WorkflowFailureReasons.PageRenderFailed, ex.Message);
+ }
+
+ using (bitmap)
+ {
+ string? qr = null;
+ if (barcodeDecoder is not null)
+ {
+ qr = barcodeDecoder.Decode(bitmap, crop: null);
+ if (qr is null && pdfOptions.FallbackTemplate?.BarcodeRegion is { } region)
+ {
+ AffineTransform identity = AffineTransform.Identity(bitmap.Width, bitmap.Height);
+ qr = barcodeDecoder.Decode(bitmap, identity.MapRect(region.Rect));
+ }
+ }
+
+ if (pdfOptions.RequireQr && barcodeDecoder is not null && string.IsNullOrWhiteSpace(qr))
+ {
+ return OmrPageResult.Failed(pageIndex, WorkflowFailureReasons.QrCodeUnreadable, "No QR code could be decoded on this page.");
+ }
+
+ OmrTemplate? template = templateResolver.Resolve(qr) ?? pdfOptions.FallbackTemplate;
+ if (template is null)
+ {
+ return OmrPageResult.Failed(
+ pageIndex,
+ WorkflowFailureReasons.TemplateNotFound,
+ "No OMR template is registered for this page.",
+ decodedQrValue: qr);
+ }
+
+ return _engine.Recognize(bitmap, template, pageIndex, qr);
+ }
+ }
+
+ private static PdfLoadedDocument? TryLoadDocument(Stream pdfStream, out OmrPageResult? failure)
+ {
+ try
+ {
+ Stream seekable = pdfStream;
+ if (!pdfStream.CanSeek)
+ {
+ MemoryStream copy = new();
+ pdfStream.CopyTo(copy);
+ copy.Position = 0;
+ seekable = copy;
+ }
+ else
+ {
+ pdfStream.Position = 0;
+ }
+
+ failure = null;
+ return new PdfLoadedDocument(seekable);
+ }
+ catch (Exception ex) when (IsPasswordError(ex))
+ {
+ failure = OmrPageResult.Failed(0, WorkflowFailureReasons.PdfPasswordProtected, ex.Message);
+ return null;
+ }
+ catch (Exception ex)
+ {
+ failure = OmrPageResult.Failed(0, WorkflowFailureReasons.PdfCorrupt, Unwrap(ex).Message);
+ return null;
+ }
+ }
+
+ private static bool IsPasswordError(Exception ex)
+ {
+ for (Exception? current = ex; current is not null; current = current.InnerException)
+ {
+ if (current.Message.Contains("password", StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static Exception Unwrap(Exception ex)
+ {
+ Exception current = ex;
+ while (current.InnerException is not null)
+ {
+ current = current.InnerException;
+ }
+
+ return current;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ if (OwnsEngine)
+ {
+ _engine.Dispose();
+ }
+ }
+}
diff --git a/OMR/.NET Core/OMR/src/Omr.Pdf/PdfPageRasterizer.cs b/OMR/.NET Core/OMR/src/Omr.Pdf/PdfPageRasterizer.cs
new file mode 100644
index 00000000..97ca802e
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Pdf/PdfPageRasterizer.cs
@@ -0,0 +1,71 @@
+using Omr.Engine;
+using SkiaSharp;
+using Syncfusion.Pdf;
+using Syncfusion.Pdf.Exporting;
+using Syncfusion.Pdf.Parsing;
+
+namespace Omr.Pdf;
+
+internal static class PdfPageRasterizer
+{
+ public static SKBitmap Rasterize(PdfLoadedDocument document, int pageIndex, PdfOmrOptions options)
+ {
+ _ = options;
+ SKBitmap? extracted = TryExtractLargestEmbeddedImage(document, pageIndex);
+ if (extracted is not null)
+ {
+ return extracted;
+ }
+
+ throw new OmrException(
+ WorkflowFailureReasons.PageRenderFailed,
+ "The page has no extractable scan image. Vector-only PDF rasterization via Pdfium is not used in this build.");
+ }
+
+ private static SKBitmap? TryExtractLargestEmbeddedImage(PdfLoadedDocument document, int pageIndex)
+ {
+ if (document.Pages[pageIndex] is not PdfLoadedPage page)
+ {
+ return null;
+ }
+
+ Stream[]? images = PdfImageExtractor.ExtractImages(page);
+ if (images is null || images.Length == 0)
+ {
+ return null;
+ }
+
+ SKBitmap? best = null;
+ int bestPixels = -1;
+ foreach (Stream imageStream in images)
+ {
+ using (imageStream)
+ {
+ if (imageStream.CanSeek)
+ {
+ imageStream.Position = 0;
+ }
+
+ SKBitmap? candidate = SKBitmap.Decode(imageStream);
+ if (candidate is null)
+ {
+ continue;
+ }
+
+ int pixels = candidate.Width * candidate.Height;
+ if (pixels > bestPixels)
+ {
+ best?.Dispose();
+ best = candidate;
+ bestPixels = pixels;
+ }
+ else
+ {
+ candidate.Dispose();
+ }
+ }
+ }
+
+ return best;
+ }
+}
diff --git a/OMR/.NET Core/OMR/src/Omr.Poc/CatalogTemplateResolver.cs b/OMR/.NET Core/OMR/src/Omr.Poc/CatalogTemplateResolver.cs
new file mode 100644
index 00000000..2b95911d
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Poc/CatalogTemplateResolver.cs
@@ -0,0 +1,41 @@
+using Omr.Engine;
+using Omr.Engine.Templates;
+
+namespace Omr.Poc;
+
+public sealed class CatalogTemplateResolver : ITemplateResolver
+{
+ private readonly Dictionary _byKey;
+
+ public CatalogTemplateResolver(params OmrTemplate[] templates)
+ {
+ _byKey = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (OmrTemplate template in templates)
+ {
+ _byKey[$"{template.TemplateId}|{template.TemplateVersion}"] = template;
+ _byKey[template.TemplateId] = template;
+ }
+ }
+
+ public OmrTemplate? Resolve(string? qrValue)
+ {
+ if (string.IsNullOrWhiteSpace(qrValue))
+ {
+ return null;
+ }
+
+ // Payload: exam-cs101|v3|student-001
+ string[] parts = qrValue.Split('|');
+ if (parts.Length >= 2)
+ {
+ string version = parts[1].StartsWith('v') ? parts[1][1..] : parts[1];
+ string key = $"{parts[0]}|{version}";
+ if (_byKey.TryGetValue(key, out OmrTemplate? exact))
+ {
+ return exact;
+ }
+ }
+
+ return _byKey.GetValueOrDefault(parts[0]);
+ }
+}
diff --git a/OMR/.NET Core/OMR/src/Omr.Poc/FolderDelivery.cs b/OMR/.NET Core/OMR/src/Omr.Poc/FolderDelivery.cs
new file mode 100644
index 00000000..7ce34ec0
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Poc/FolderDelivery.cs
@@ -0,0 +1,86 @@
+using System.Text;
+using Omr.Engine.Results;
+
+namespace Omr.Poc;
+
+public sealed class PageDelivery
+{
+ public required int PageIndex { get; init; }
+
+ public required string StudentId { get; init; }
+
+ public required string ExamId { get; init; }
+
+ public required double Grade { get; init; }
+
+ public required bool NeedsReview { get; init; }
+
+ public required string PageStatus { get; init; }
+
+ public required string? FailureReason { get; init; }
+
+ public required string Answers { get; init; }
+
+ public required string Confidence { get; init; }
+
+ public required string Diagnostics { get; init; }
+
+ public required string JsonPath { get; init; }
+
+ public required string ImagePath { get; init; }
+}
+
+public interface IResultDelivery
+{
+ Task DeliverAsync(PageDelivery page, CancellationToken cancellationToken = default);
+
+ Task CompleteAsync(IReadOnlyList pages, CancellationToken cancellationToken = default);
+}
+
+public sealed class FolderDelivery : IResultDelivery
+{
+ public FolderDelivery(string outputDirectory)
+ {
+ OutputDirectory = outputDirectory;
+ Directory.CreateDirectory(OutputDirectory);
+ }
+
+ public string OutputDirectory { get; }
+
+ public Task DeliverAsync(PageDelivery page, CancellationToken cancellationToken = default) => Task.CompletedTask;
+
+ public async Task CompleteAsync(IReadOnlyList pages, CancellationToken cancellationToken = default)
+ {
+ string csvPath = Path.Combine(OutputDirectory, "summary.csv");
+ StringBuilder csv = new();
+ csv.AppendLine("pageIndex,studentId,examId,grade,needsReview,pageStatus,failureReason,answers,confidence,diagnostics,jsonPath,imagePath");
+ foreach (PageDelivery page in pages)
+ {
+ csv.AppendLine(string.Join(',',
+ page.PageIndex,
+ Csv(page.StudentId),
+ Csv(page.ExamId),
+ page.Grade.ToString("0.##"),
+ page.NeedsReview,
+ Csv(page.PageStatus),
+ Csv(page.FailureReason ?? ""),
+ Csv(page.Answers),
+ Csv(page.Confidence),
+ Csv(page.Diagnostics),
+ Csv(page.JsonPath),
+ Csv(page.ImagePath)));
+ }
+
+ await File.WriteAllTextAsync(csvPath, csv.ToString(), cancellationToken);
+ }
+
+ private static string Csv(string value)
+ {
+ if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
+ {
+ return '"' + value.Replace("\"", "\"\"") + '"';
+ }
+
+ return value;
+ }
+}
diff --git a/OMR/.NET Core/OMR/src/Omr.Poc/GradeAnnotator.cs b/OMR/.NET Core/OMR/src/Omr.Poc/GradeAnnotator.cs
new file mode 100644
index 00000000..57c845f7
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Poc/GradeAnnotator.cs
@@ -0,0 +1,83 @@
+using Omr.Engine;
+using Omr.Engine.Geometry;
+using Omr.Engine.Processing;
+using Omr.Engine.Results;
+using Omr.Engine.Templates;
+using SkiaSharp;
+
+namespace Omr.Poc;
+
+public static class GradeAnnotator
+{
+ public static SKBitmap Annotate(SKBitmap source, OmrTemplate template, OmrPageResult page, GradeResult grade)
+ {
+ AffineTransform transform = AffineTransform.Identity(source.Width, source.Height);
+ SKBitmap overlay = OmrDiagnosticsOverlay.Render(source, template, page, transform);
+ using SKCanvas canvas = new(overlay);
+ using SKFont font = new(SKTypeface.Default, Math.Max(16, overlay.Width / 55f));
+ using SKPaint text = new() { IsAntialias = true };
+
+ IReadOnlyDictionary key = new Dictionary
+ {
+ ["q1"] = "B",
+ ["q2"] = "A",
+ ["q3"] = "D",
+ ["q4"] = "C",
+ ["q5"] = "B"
+ };
+
+ foreach (OmrGroupResult group in page.Groups.Where(g => g.GroupKind == GroupKind.Answer))
+ {
+ key.TryGetValue(group.Id, out string? correct);
+ PixelRect band = Band(group);
+ SKColor color = group.Status switch
+ {
+ GroupStatus.Selected when correct is not null && group.SelectedOptionIds.Contains(correct)
+ => new SKColor(0, 150, 60),
+ GroupStatus.Selected => new SKColor(200, 30, 30),
+ GroupStatus.Blank => new SKColor(180, 120, 0),
+ GroupStatus.Multiple => new SKColor(200, 80, 0),
+ GroupStatus.Ambiguous => new SKColor(200, 80, 0),
+ GroupStatus.Unreadable => new SKColor(90, 90, 90),
+ _ => throw new InvalidOperationException($"Unhandled group status {group.Status}.")
+ };
+
+ using SKPaint fill = new() { Color = color.WithAlpha(40), Style = SKPaintStyle.Fill };
+ using SKPaint stroke = new() { Color = color, Style = SKPaintStyle.Stroke, StrokeWidth = 3, IsAntialias = true };
+ canvas.DrawRect(band.X, band.Y, band.Width, band.Height, fill);
+ canvas.DrawRect(band.X, band.Y, band.Width, band.Height, stroke);
+
+ string mark = group.Status switch
+ {
+ GroupStatus.Selected when correct is not null && group.SelectedOptionIds.Contains(correct) => "OK",
+ GroupStatus.Selected => "X",
+ GroupStatus.Blank => "—",
+ GroupStatus.Multiple => "M",
+ GroupStatus.Ambiguous => "?",
+ GroupStatus.Unreadable => "!",
+ _ => throw new InvalidOperationException($"Unhandled group status {group.Status}.")
+ };
+
+ text.Color = color;
+ canvas.DrawText($"{group.Id} {mark}", band.X + band.Width + 8, band.Y + (band.Height / 2f) + (font.Size / 3f), font, text);
+ }
+
+ text.Color = grade.NeedsReview ? new SKColor(180, 80, 0) : new SKColor(0, 90, 40);
+ canvas.DrawText($"Grade {grade.Score:0.##} review={grade.NeedsReview}", 20, overlay.Height - 24, font, text);
+ return overlay;
+ }
+
+ private static PixelRect Band(OmrGroupResult group)
+ {
+ if (group.Options.Count == 0)
+ {
+ return new PixelRect(0, 0, 1, 1);
+ }
+
+ int x0 = group.Options.Min(o => o.PixelRect.X);
+ int y0 = group.Options.Min(o => o.PixelRect.Y);
+ int x1 = group.Options.Max(o => o.PixelRect.X + o.PixelRect.Width);
+ int y1 = group.Options.Max(o => o.PixelRect.Y + o.PixelRect.Height);
+ return new PixelRect(x0 - 4, y0 - 4, x1 - x0 + 8, y1 - y0 + 8);
+ }
+}
diff --git a/OMR/.NET Core/OMR/src/Omr.Poc/Omr.Poc.csproj b/OMR/.NET Core/OMR/src/Omr.Poc/Omr.Poc.csproj
new file mode 100644
index 00000000..10476969
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Poc/Omr.Poc.csproj
@@ -0,0 +1,28 @@
+
+
+
+ Exe
+ net9.0
+ Omr.Poc
+ true
+
+
+
+
+
+
+
+
+
+
+
+ ..\..\lib\Omr.Engine.dll
+
+
+
+
+
+
+
+
+
diff --git a/OMR/.NET Core/OMR/src/Omr.Poc/Program.cs b/OMR/.NET Core/OMR/src/Omr.Poc/Program.cs
new file mode 100644
index 00000000..b94c9fdd
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Poc/Program.cs
@@ -0,0 +1,209 @@
+using Omr.Engine;
+using Omr.Engine.Results;
+using Omr.Engine.Templates;
+using Omr.Pdf;
+using Omr.Poc;
+using SkiaSharp;
+using Syncfusion.Pdf;
+using Syncfusion.Pdf.Graphics;
+using Syncfusion.Licensing;
+
+string? license = Environment.GetEnvironmentVariable("SYNCFUSION_LICENSE");
+if (!string.IsNullOrWhiteSpace(license))
+{
+ SyncfusionLicenseProvider.RegisterLicense(license);
+}
+
+string root = FindRepoRoot();
+string templatePath = Path.Combine(root, "samples", "templates", "exam-cs101-v3.json");
+if (!File.Exists(templatePath))
+{
+ templatePath = Path.Combine(AppContext.BaseDirectory, "templates", "exam-cs101-v3.json");
+}
+
+OmrTemplate template = OmrTemplate.Load(templatePath);
+template.EnsureValid();
+
+int pageCount = GetIntArg("--pages", 3);
+string outputDir = GetArg("--out") ?? Path.Combine(root, "poc-output");
+Directory.CreateDirectory(outputDir);
+string pdfPath = Path.Combine(outputDir, "exam-batch.pdf");
+
+List sheets = BuildSheets(pageCount);
+SampleExamFactory.WritePdf(pdfPath, template, sheets, dpi: 150);
+Console.WriteLine($"Wrote {pdfPath} ({pageCount} pages).");
+
+CatalogTemplateResolver resolver = new(template);
+ZXingBarcodeDecoder decoder = new();
+OmrRecognitionOptions engineOptions = new()
+{
+ AutoRotate = false,
+ Deskew = false,
+ FilledThreshold = 0.55f,
+ BlankThreshold = 0.18f,
+ MinAlignmentScore = 0.30f,
+ MinEffectiveDpi = 100f
+};
+
+using OmrProcessor engine = new(engineOptions);
+using PdfOmrProcessor pdfProcessor = new(engine);
+IExamGrader grader = WeightedConfidenceGrader.Cs101V3();
+FolderDelivery delivery = new(outputDir);
+List delivered = [];
+int completed = 0;
+
+using CancellationTokenSource cts = new();
+Console.CancelKeyPress += (_, e) =>
+{
+ e.Cancel = true;
+ cts.Cancel();
+};
+
+Progress progress = new(p =>
+{
+ Console.WriteLine($"page {p.PageIndex} {p.Status} ({p.PagesCompleted}/{pageCount})");
+});
+
+using PdfDocument annotatedPdf = new();
+annotatedPdf.PageSettings.Margins.All = 0;
+annotatedPdf.PageSettings.Size = PdfPageSize.Letter;
+
+await foreach (OmrPageResult page in pdfProcessor.RecognizeAsync(
+ pdfPath,
+ resolver,
+ decoder,
+ new PdfOmrOptions { Dpi = 150, RequireQr = true, FallbackTemplate = template },
+ progress,
+ cts.Token))
+{
+ completed++;
+ GradeResult grade = grader.Grade(page);
+ string jsonPath = Path.Combine(outputDir, $"page-{page.SourcePageIndex:000}.json");
+ await File.WriteAllTextAsync(jsonPath, page.ToJson(), cts.Token);
+
+ string imagePath = Path.Combine(outputDir, $"page-{page.SourcePageIndex:000}.png");
+ using (SKBitmap source = SampleExamFactory.RenderSheet(template, sheets[page.SourcePageIndex], 150))
+ using (SKBitmap annotated = GradeAnnotator.Annotate(source, template, page, grade))
+ using (SKFileWStream stream = new(imagePath))
+ {
+ annotated.Encode(stream, SKEncodedImageFormat.Png, 90);
+ using MemoryStream png = new();
+ annotated.Encode(png, SKEncodedImageFormat.Png, 90);
+ png.Position = 0;
+ var pdfPage = annotatedPdf.Pages.Add();
+ using PdfBitmap pdfImage = new(png);
+ var size = pdfPage.GetClientSize();
+ pdfPage.Graphics.DrawImage(pdfImage, 0, 0, size.Width, size.Height);
+ }
+
+ string answers = string.Join(';', page.Groups.Where(g => g.GroupKind == GroupKind.Answer)
+ .Select(g => $"{g.Id}={string.Join('|', g.SelectedOptionIds)}:{g.Status}"));
+ string confidence = string.Join(';', page.Groups.Where(g => g.GroupKind == GroupKind.Confidence)
+ .Select(g => $"{g.Id}={string.Join('|', g.SelectedOptionIds)}:{g.Status}"));
+
+ PageDelivery row = new()
+ {
+ PageIndex = page.SourcePageIndex,
+ StudentId = grade.StudentId,
+ ExamId = grade.ExamId,
+ Grade = grade.Score,
+ NeedsReview = grade.NeedsReview,
+ PageStatus = page.PageStatus.ToString(),
+ FailureReason = page.FailureReason,
+ Answers = answers,
+ Confidence = confidence,
+ Diagnostics = string.Join('|', page.Warnings) + (grade.Notes.Count == 0 ? "" : ";" + string.Join('|', grade.Notes)),
+ JsonPath = jsonPath,
+ ImagePath = imagePath
+ };
+ delivered.Add(row);
+ await delivery.DeliverAsync(row, cts.Token);
+}
+
+string annotatedPath = Path.Combine(outputDir, "annotated.pdf");
+await using (FileStream annotatedStream = File.Create(annotatedPath))
+{
+ annotatedPdf.Save(annotatedStream);
+}
+
+await delivery.CompleteAsync(delivered, cts.Token);
+OmrBatchSummary summary = OmrBatchSummary.From(delivered.Select((_, i) => new OmrPageResult { PageStatus = Enum.Parse(delivered[i].PageStatus) }));
+Console.WriteLine($"Done. succeeded={summary.Succeeded} review={summary.NeedsReview} failed={summary.Failed} skipped={summary.Skipped}");
+Console.WriteLine($"CSV: {Path.Combine(outputDir, "summary.csv")}");
+Console.WriteLine($"Annotated PDF: {annotatedPath}");
+
+static List BuildSheets(int count)
+{
+ List sheets = [];
+ for (int i = 0; i < count; i++)
+ {
+ int variant = i % 4;
+ Dictionary answers = new()
+ {
+ ["q1"] = "B",
+ ["q2"] = "A",
+ ["q3"] = "D",
+ ["q4"] = "C",
+ ["q5"] = "B"
+ };
+ Dictionary confidence = new()
+ {
+ ["q1"] = "H",
+ ["q2"] = "H",
+ ["q3"] = "M",
+ ["q4"] = "H",
+ ["q5"] = "L"
+ };
+ HashSet? extra = null;
+ switch (variant)
+ {
+ case 0:
+ break;
+ case 1:
+ answers["q2"] = "C";
+ break;
+ case 2:
+ answers.Remove("q5");
+ confidence.Remove("q5");
+ break;
+ case 3:
+ extra = ["q1:A"];
+ break;
+ default:
+ throw new InvalidOperationException($"Unhandled variant {variant}.");
+ }
+
+ sheets.Add(new SheetMarks($"student-{i + 1:000}", answers, confidence, extra));
+ }
+
+ return sheets;
+}
+
+static string FindRepoRoot()
+{
+ string dir = AppContext.BaseDirectory;
+ while (!string.IsNullOrEmpty(dir))
+ {
+ if (File.Exists(Path.Combine(dir, "Omr.sln")))
+ {
+ return dir;
+ }
+
+ dir = Directory.GetParent(dir)?.FullName ?? "";
+ }
+
+ return Directory.GetCurrentDirectory();
+}
+
+static string? GetArg(string name)
+{
+ string[] args = Environment.GetCommandLineArgs();
+ int i = Array.IndexOf(args, name);
+ return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;
+}
+
+static int GetIntArg(string name, int fallback)
+{
+ string? raw = GetArg(name);
+ return int.TryParse(raw, out int value) ? value : fallback;
+}
diff --git a/OMR/.NET Core/OMR/src/Omr.Poc/SampleExamFactory.cs b/OMR/.NET Core/OMR/src/Omr.Poc/SampleExamFactory.cs
new file mode 100644
index 00000000..078ffe08
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Poc/SampleExamFactory.cs
@@ -0,0 +1,139 @@
+using Omr.Engine.Geometry;
+using Omr.Engine.Templates;
+using SkiaSharp;
+using Syncfusion.Pdf;
+using Syncfusion.Pdf.Graphics;
+
+namespace Omr.Poc;
+
+public sealed record SheetMarks(
+ string StudentId,
+ IReadOnlyDictionary Answers,
+ IReadOnlyDictionary Confidence,
+ IReadOnlySet? ExtraFilledAnswers = null);
+
+public static class SampleExamFactory
+{
+ public static string QrPayload(string studentId) => $"exam-cs101|v3|{studentId}";
+
+ public static SKBitmap RenderSheet(OmrTemplate template, SheetMarks marks, int dpi = 150)
+ {
+ int width = (int)(template.Page.WidthInches * dpi);
+ int height = (int)(template.Page.HeightInches * dpi);
+ SKBitmap bitmap = new(width, height, SKColorType.Bgra8888, SKAlphaType.Opaque);
+ using SKCanvas canvas = new(bitmap);
+ canvas.Clear(SKColors.White);
+
+ using SKPaint black = new() { Color = SKColors.Black, IsAntialias = true, Style = SKPaintStyle.Fill };
+ using SKPaint stroke = new()
+ {
+ Color = SKColors.Black,
+ IsAntialias = true,
+ Style = SKPaintStyle.Stroke,
+ StrokeWidth = Math.Max(2, width / 500f)
+ };
+ using SKFont font = new(SKTypeface.Default, Math.Max(14, width / 55f));
+ using SKPaint text = new() { Color = SKColors.Black, IsAntialias = true };
+
+ canvas.DrawText("CS101 answer sheet", Px(template, 0.16f, true, width), Px(template, 0.12f, false, height), font, text);
+
+ foreach (OmrRegion region in template.Regions)
+ {
+ SKRect rect = ToRect(region.Rect, width, height);
+ switch (region.Kind)
+ {
+ case Omr.Engine.RegionKind.Anchor:
+ canvas.DrawRect(rect, black);
+ break;
+ case Omr.Engine.RegionKind.Barcode:
+ using (SKBitmap qr = ZXingBarcodeDecoder.EncodeQr(QrPayload(marks.StudentId), (int)rect.Width, (int)rect.Height))
+ {
+ canvas.DrawBitmap(qr, rect);
+ }
+
+ break;
+ case Omr.Engine.RegionKind.Mark:
+ break;
+ default:
+ throw new InvalidOperationException($"Unhandled region kind {region.Kind}.");
+ }
+ }
+
+ foreach (OmrGroup group in template.Groups)
+ {
+ foreach (OmrMark mark in group.Marks)
+ {
+ SKRect rect = ToRect(mark.Rect, width, height);
+ canvas.DrawOval(rect, stroke);
+ bool fill = IsFilled(group, mark, marks);
+ if (fill)
+ {
+ rect.Inflate(-rect.Width * 0.18f, -rect.Height * 0.18f);
+ canvas.DrawOval(rect, black);
+ }
+ }
+ }
+
+ return bitmap;
+ }
+
+ public static void WritePdf(string path, OmrTemplate template, IReadOnlyList sheets, int dpi = 150)
+ {
+ using PdfDocument document = new();
+ document.PageSettings.Margins.All = 0;
+ document.PageSettings.Size = PdfPageSize.Letter;
+ document.PageSettings.Orientation = PdfPageOrientation.Portrait;
+
+ foreach (SheetMarks sheet in sheets)
+ {
+ using SKBitmap raster = RenderSheet(template, sheet, dpi);
+ using MemoryStream png = new();
+ raster.Encode(png, SKEncodedImageFormat.Png, 90);
+ png.Position = 0;
+
+ PdfPage page = document.Pages.Add();
+ using PdfBitmap image = new(png);
+ var size = page.GetClientSize();
+ page.Graphics.DrawImage(image, 0, 0, size.Width, size.Height);
+ }
+
+ using FileStream output = File.Create(path);
+ document.Save(output);
+ }
+
+ private static bool IsFilled(OmrGroup group, OmrMark mark, SheetMarks marks)
+ {
+ if (group.GroupKind == Omr.Engine.GroupKind.Answer)
+ {
+ if (marks.Answers.TryGetValue(group.Id, out string? selected) && selected == mark.Id)
+ {
+ return true;
+ }
+
+ return marks.ExtraFilledAnswers is not null && marks.ExtraFilledAnswers.Contains($"{group.Id}:{mark.Id}");
+ }
+
+ if (group.GroupKind == Omr.Engine.GroupKind.Confidence)
+ {
+ string questionId = group.Id.Replace("-confidence", "", StringComparison.Ordinal);
+ return marks.Confidence.TryGetValue(questionId, out string? level) && level == mark.Id;
+ }
+
+ return false;
+ }
+
+ private static SKRect ToRect(NormalizedRect rect, int width, int height)
+ {
+ return new SKRect(
+ rect.X * width,
+ rect.Y * height,
+ rect.Right * width,
+ rect.Bottom * height);
+ }
+
+ private static float Px(OmrTemplate template, float n, bool horizontal, int size)
+ {
+ _ = template;
+ return horizontal ? n * size : n * size;
+ }
+}
diff --git a/OMR/.NET Core/OMR/src/Omr.Poc/WeightedConfidenceGrader.cs b/OMR/.NET Core/OMR/src/Omr.Poc/WeightedConfidenceGrader.cs
new file mode 100644
index 00000000..691b7318
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Poc/WeightedConfidenceGrader.cs
@@ -0,0 +1,152 @@
+using Omr.Engine;
+using Omr.Engine.Results;
+
+namespace Omr.Poc;
+
+public sealed class GradeResult
+{
+ public required string StudentId { get; init; }
+
+ public required string ExamId { get; init; }
+
+ public required double Score { get; init; }
+
+ public required int Awarded { get; init; }
+
+ public required int Possible { get; init; }
+
+ public required bool NeedsReview { get; init; }
+
+ public required IReadOnlyList Notes { get; init; }
+}
+
+public interface IExamGrader
+{
+ GradeResult Grade(OmrPageResult page);
+}
+
+///
+/// Example policy: correct answer with High=1, Medium=0.5, Low=0.25.
+/// Blank, multiple, and ambiguous answers score 0 and flag review.
+///
+public sealed class WeightedConfidenceGrader : IExamGrader
+{
+ private readonly IReadOnlyDictionary _answerKey;
+
+ public WeightedConfidenceGrader(IReadOnlyDictionary answerKey)
+ {
+ _answerKey = answerKey;
+ }
+
+ public static WeightedConfidenceGrader Cs101V3() => new(new Dictionary
+ {
+ ["q1"] = "B",
+ ["q2"] = "A",
+ ["q3"] = "D",
+ ["q4"] = "C",
+ ["q5"] = "B"
+ });
+
+ public GradeResult Grade(OmrPageResult page)
+ {
+ string studentId = "unknown";
+ string examId = page.TemplateId ?? "unknown";
+ if (!string.IsNullOrWhiteSpace(page.DecodedQrValue))
+ {
+ string[] parts = page.DecodedQrValue.Split('|');
+ if (parts.Length >= 1)
+ {
+ examId = parts[0];
+ }
+
+ if (parts.Length >= 3)
+ {
+ studentId = parts[2];
+ }
+ }
+
+ double awarded = 0;
+ int possible = _answerKey.Count;
+ List notes = [];
+ bool review = page.PageStatus is PageStatus.NeedsReview or PageStatus.Failed;
+
+ foreach ((string questionId, string correct) in _answerKey)
+ {
+ OmrGroupResult? answer = page.Groups.FirstOrDefault(g => g.Id == questionId);
+ OmrGroupResult? confidence = page.Groups.FirstOrDefault(g => g.Id == questionId + "-confidence");
+ if (answer is null)
+ {
+ notes.Add($"{questionId}: missing");
+ review = true;
+ continue;
+ }
+
+ switch (answer.Status)
+ {
+ case GroupStatus.Selected:
+ if (answer.SelectedOptionIds.Count == 1 && answer.SelectedOptionIds[0] == correct)
+ {
+ awarded += ConfidenceWeight(confidence);
+ }
+ else
+ {
+ notes.Add($"{questionId}: incorrect");
+ }
+
+ break;
+ case GroupStatus.Blank:
+ notes.Add($"{questionId}: blank");
+ review = true;
+ break;
+ case GroupStatus.Multiple:
+ notes.Add($"{questionId}: multiple");
+ review = true;
+ break;
+ case GroupStatus.Ambiguous:
+ notes.Add($"{questionId}: ambiguous");
+ review = true;
+ break;
+ case GroupStatus.Unreadable:
+ notes.Add($"{questionId}: unreadable");
+ review = true;
+ break;
+ default:
+ throw new InvalidOperationException($"Unhandled group status {answer.Status}.");
+ }
+
+ if (confidence is not null && confidence.Status is not GroupStatus.Selected and not GroupStatus.Blank)
+ {
+ notes.Add($"{questionId}: confidence {confidence.Status}");
+ review = true;
+ }
+ }
+
+ double score = possible == 0 ? 0 : Math.Round(100.0 * awarded / possible, 2);
+ return new GradeResult
+ {
+ StudentId = studentId,
+ ExamId = examId,
+ Score = score,
+ Awarded = (int)Math.Round(awarded * 100),
+ Possible = possible * 100,
+ NeedsReview = review,
+ Notes = notes
+ };
+ }
+
+ private static double ConfidenceWeight(OmrGroupResult? confidence)
+ {
+ if (confidence is null || confidence.Status != GroupStatus.Selected || confidence.SelectedOptionIds.Count != 1)
+ {
+ return 1.0;
+ }
+
+ return confidence.SelectedOptionIds[0] switch
+ {
+ "H" => 1.0,
+ "M" => 0.5,
+ "L" => 0.25,
+ _ => 0
+ };
+ }
+}
diff --git a/OMR/.NET Core/OMR/src/Omr.Poc/ZXingBarcodeDecoder.cs b/OMR/.NET Core/OMR/src/Omr.Poc/ZXingBarcodeDecoder.cs
new file mode 100644
index 00000000..3f05d95c
--- /dev/null
+++ b/OMR/.NET Core/OMR/src/Omr.Poc/ZXingBarcodeDecoder.cs
@@ -0,0 +1,149 @@
+using Omr.Engine;
+using Omr.Engine.Geometry;
+using SkiaSharp;
+using ZXing;
+using ZXing.Common;
+using ZXing.QrCode;
+using ZXing.Rendering;
+
+namespace Omr.Poc;
+
+public sealed class ZXingBarcodeDecoder : IBarcodeDecoder
+{
+ public string? Decode(SKBitmap page, PixelRect? crop)
+ {
+ using SKBitmap target = Crop(page, crop);
+ byte[] rgb = ToRgb(target);
+ RGBLuminanceSource source = new(rgb, target.Width, target.Height);
+ BarcodeReaderGeneric reader = new()
+ {
+ AutoRotate = true,
+ Options = new DecodingOptions
+ {
+ PossibleFormats = [BarcodeFormat.QR_CODE],
+ TryHarder = true,
+ PureBarcode = false
+ }
+ };
+
+ Result? result = reader.Decode(source);
+ return result?.Text;
+ }
+
+ public static SKBitmap EncodeQr(string payload, int width, int height)
+ {
+ BarcodeWriterPixelData writer = new()
+ {
+ Format = BarcodeFormat.QR_CODE,
+ Options = new QrCodeEncodingOptions
+ {
+ Width = Math.Max(32, width),
+ Height = Math.Max(32, height),
+ Margin = 1,
+ CharacterSet = "UTF-8"
+ }
+ };
+
+ PixelData data = writer.Write(payload);
+ SKBitmap bitmap = new(data.Width, data.Height, SKColorType.Bgra8888, SKAlphaType.Opaque);
+ IntPtr dest = bitmap.GetPixels();
+ if (dest == IntPtr.Zero)
+ {
+ throw new InvalidOperationException("QR bitmap has no pixel buffer.");
+ }
+
+ unsafe
+ {
+ byte* d = (byte*)dest.ToPointer();
+ byte[] pixels = data.Pixels;
+ int n = data.Width * data.Height;
+ int srcStride = pixels.Length >= n * 4 ? 4 : 3;
+ for (int i = 0; i < n; i++)
+ {
+ int s = i * srcStride;
+ int p = i * 4;
+ if (srcStride == 4)
+ {
+ d[p] = pixels[s + 2];
+ d[p + 1] = pixels[s + 1];
+ d[p + 2] = pixels[s];
+ d[p + 3] = 255;
+ }
+ else
+ {
+ d[p] = pixels[s + 2];
+ d[p + 1] = pixels[s + 1];
+ d[p + 2] = pixels[s];
+ d[p + 3] = 255;
+ }
+ }
+ }
+
+ return bitmap;
+ }
+
+ private static SKBitmap Crop(SKBitmap page, PixelRect? crop)
+ {
+ if (crop is null)
+ {
+ return page.Copy() ?? throw new InvalidOperationException("Could not copy the page bitmap.");
+ }
+
+ PixelRect r = crop.Value;
+ int x = Math.Clamp(r.X, 0, page.Width - 1);
+ int y = Math.Clamp(r.Y, 0, page.Height - 1);
+ int w = Math.Clamp(r.Width, 1, page.Width - x);
+ int h = Math.Clamp(r.Height, 1, page.Height - y);
+ SKBitmap part = new(w, h, SKColorType.Bgra8888, SKAlphaType.Opaque);
+ using SKCanvas canvas = new(part);
+ canvas.Clear(SKColors.White);
+ canvas.DrawBitmap(page, new SKRect(x, y, x + w, y + h), new SKRect(0, 0, w, h));
+ return part;
+ }
+
+ private static byte[] ToRgb(SKBitmap bitmap)
+ {
+ SKBitmap? copy = null;
+ SKBitmap bgra = bitmap;
+ if (bitmap.ColorType != SKColorType.Bgra8888)
+ {
+ copy = bitmap.Copy(SKColorType.Bgra8888)
+ ?? throw new InvalidOperationException("Could not convert the barcode crop to BGRA.");
+ bgra = copy;
+ }
+
+ try
+ {
+ IntPtr srcPtr = bgra.GetPixels();
+ if (srcPtr == IntPtr.Zero)
+ {
+ throw new InvalidOperationException("Barcode crop has no pixel buffer.");
+ }
+
+ byte[] rgb = new byte[bgra.Width * bgra.Height * 3];
+ unsafe
+ {
+ byte* src = (byte*)srcPtr.ToPointer();
+ int stride = bgra.RowBytes;
+ int i = 0;
+ for (int y = 0; y < bgra.Height; y++)
+ {
+ byte* row = src + (y * stride);
+ for (int x = 0; x < bgra.Width; x++)
+ {
+ int p = x * 4;
+ rgb[i++] = row[p + 2];
+ rgb[i++] = row[p + 1];
+ rgb[i++] = row[p];
+ }
+ }
+ }
+
+ return rgb;
+ }
+ finally
+ {
+ copy?.Dispose();
+ }
+ }
+}