From 783f94ace29c304c4d667e2070b15875c541d93d Mon Sep 17 00:00:00 2001 From: Moorthy K Date: Wed, 26 Aug 2026 18:06:25 +0530 Subject: [PATCH] 1049972 : Optical Mark Recognition (OMR) engine for detecting and extracting using Syncfusion --- OMR/.NET Core/OMR/.gitignore | 10 + OMR/.NET Core/OMR/Directory.Build.props | 9 + OMR/.NET Core/OMR/LICENSE | 24 ++ OMR/.NET Core/OMR/Omr.sln | 54 +++++ OMR/.NET Core/OMR/README.md | 217 +++++++++++++++++ OMR/.NET Core/OMR/lib/Omr.Engine.deps.json | 154 ++++++++++++ OMR/.NET Core/OMR/lib/Omr.Engine.dll | Bin 0 -> 65536 bytes OMR/.NET Core/OMR/lib/Omr.Engine.pdb | Bin 0 -> 29260 bytes .../OMR/samples/templates/exam-cs101-v3.json | 130 +++++++++++ OMR/.NET Core/OMR/src/Omr.Pdf/Omr.Pdf.csproj | 22 ++ .../OMR/src/Omr.Pdf/PdfOmrOptions.cs | 26 +++ .../OMR/src/Omr.Pdf/PdfOmrProcessor.cs | 220 ++++++++++++++++++ .../OMR/src/Omr.Pdf/PdfPageRasterizer.cs | 71 ++++++ .../src/Omr.Poc/CatalogTemplateResolver.cs | 41 ++++ .../OMR/src/Omr.Poc/FolderDelivery.cs | 86 +++++++ .../OMR/src/Omr.Poc/GradeAnnotator.cs | 83 +++++++ OMR/.NET Core/OMR/src/Omr.Poc/Omr.Poc.csproj | 28 +++ OMR/.NET Core/OMR/src/Omr.Poc/Program.cs | 209 +++++++++++++++++ .../OMR/src/Omr.Poc/SampleExamFactory.cs | 139 +++++++++++ .../src/Omr.Poc/WeightedConfidenceGrader.cs | 152 ++++++++++++ .../OMR/src/Omr.Poc/ZXingBarcodeDecoder.cs | 149 ++++++++++++ 21 files changed, 1824 insertions(+) create mode 100644 OMR/.NET Core/OMR/.gitignore create mode 100644 OMR/.NET Core/OMR/Directory.Build.props create mode 100644 OMR/.NET Core/OMR/LICENSE create mode 100644 OMR/.NET Core/OMR/Omr.sln create mode 100644 OMR/.NET Core/OMR/README.md create mode 100644 OMR/.NET Core/OMR/lib/Omr.Engine.deps.json create mode 100644 OMR/.NET Core/OMR/lib/Omr.Engine.dll create mode 100644 OMR/.NET Core/OMR/lib/Omr.Engine.pdb create mode 100644 OMR/.NET Core/OMR/samples/templates/exam-cs101-v3.json create mode 100644 OMR/.NET Core/OMR/src/Omr.Pdf/Omr.Pdf.csproj create mode 100644 OMR/.NET Core/OMR/src/Omr.Pdf/PdfOmrOptions.cs create mode 100644 OMR/.NET Core/OMR/src/Omr.Pdf/PdfOmrProcessor.cs create mode 100644 OMR/.NET Core/OMR/src/Omr.Pdf/PdfPageRasterizer.cs create mode 100644 OMR/.NET Core/OMR/src/Omr.Poc/CatalogTemplateResolver.cs create mode 100644 OMR/.NET Core/OMR/src/Omr.Poc/FolderDelivery.cs create mode 100644 OMR/.NET Core/OMR/src/Omr.Poc/GradeAnnotator.cs create mode 100644 OMR/.NET Core/OMR/src/Omr.Poc/Omr.Poc.csproj create mode 100644 OMR/.NET Core/OMR/src/Omr.Poc/Program.cs create mode 100644 OMR/.NET Core/OMR/src/Omr.Poc/SampleExamFactory.cs create mode 100644 OMR/.NET Core/OMR/src/Omr.Poc/WeightedConfidenceGrader.cs create mode 100644 OMR/.NET Core/OMR/src/Omr.Poc/ZXingBarcodeDecoder.cs 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 0000000000000000000000000000000000000000..6bcd17fd401ede084cc49d08554c84fc21357498 GIT binary patch literal 65536 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⪙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
  • 2IpvOMgfGdHTEfj-@^~G=c9o)$a@) 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

    ib61^rOY|MiZuml5&H?d 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a2fc993df417614fb978803d581db5bbdb840398 GIT binary patch literal 29260 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 Uv`{Ld%1pW$F_t%oM`alMe^q|L^#A|> literal 0 HcmV?d00001 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(); + } + } +}