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(); + } + } +}