Generate, scan, and grade multiple-choice exams — without a Scantron machine.
SnapGrade turns a stack of filled-in answer sheets into scored, exportable results in minutes: print sheets with a barcode/QR baked in, scan or photograph them once filled out, and get back per-question, per-student results as Excel — no proprietary hardware, no per-sheet licensing, no manual bubble-counting.
An independent portfolio project — built solo, from scratch, with public libraries only.
Status: the full pipeline is live end to end — generate print-ready sheets, grade scanned/photographed sheets or a re-uploaded PDF, and export Excel results, all driven from the desktop app itself (see Architecture for how it's built and calibrated). See Roadmap for what's next.
Grading multiple-choice exams by hand doesn't scale, and dedicated OMR/Scantron hardware is expensive, proprietary, and often locked to one paper format. SnapGrade is the middle ground: any printer, any scanner or phone camera, and a small desktop app that handles sheet design, optical mark recognition, and results export end to end.
Two modes share one generate → grade → export pipeline:
- Anonymous — no student data at generation time. Each sheet gets a sequential ID encoded in a barcode; grading matches results back to that ID only.
- Roster — import a student list first (Excel). Each sheet is prefilled with a student's name/ID, and the barcode encodes their student ID instead.
- Generate — fill in exam details and produce print-ready sheets. Bubble-grid layout (columns, rows, bubble size) is computed automatically from the question/option counts — never shrunk below a fillable size, never overflowing the page. Sheets export as individual PDFs or as one combined, multi-page PDF for a whole batch/roster in a single print job.
- Grade — upload a correction matrix (Excel) and the scanned/photographed sheets (a folder of images, or a single PDF — rasterized page by page automatically). Each sheet's barcode resolves its identity, its QR resolves the exact grid layout to expect, and every bubble is classified by ink-fill ratio — calibrated against measured data, not guesses. Anything ambiguous (two filled options on one question) is flagged for review, never silently guessed. One unreadable sheet doesn't abort the rest of the batch.
- Export — one Excel file: a
Summarysheet (one row per student, totals + score) and aDetailsheet (one column per question, correct/incorrect colored automatically when a correction matrix is used), merged with roster data if provided.
Full walkthrough with screens: Usage Guide. Design decisions and how the OMR pipeline was actually calibrated: Architecture.
The parts of this project that were actually hard, condensed to one line each — full reasoning for each is linked:
- Shared geometry, not duplicated math — the generator and grader both build the
exact same
SheetGeometryfrom a spec; the grader never re-detects where a bubble is, only what's drawn there, so the two halves of the app can't silently drift apart as either one changes. → architecture - Calibrated against measured data, not assumptions — the OMR fill-threshold (0.8) came from actually measuring ink-fill ratios on a known-marks test fixture, not a guess; a crossed-out bubble measurably carries more ink than expected, which is exactly the kind of thing you only catch by measuring. → architecture
- Never silently guesses — two filled options on one question is flagged
AMBIGUOUSfor human review, never resolved by picking one; a filled-then-crossed bubble correctly resolves back to a clean answer instead of a false ambiguous flag. - One computation, two consumers — the Grade screen's live results table and the
exported Excel
Summarysheet compute their numbers from the same function, so the UI and the spreadsheet can never quietly disagree. - The app's theme, never the OS's — Qt6 silently adapts unstyled widget chrome to the OS's light/dark setting independently of an app's own stylesheet; every theme here is pinned to render consistently regardless of system appearance. → architecture
Pick Anonymous or Roster mode, describe the exam, and choose where the PDFs go.
Click Generate sheets and every sheet renders, writes to individual + combined PDFs, and the first sheet previews right there — school/exam name, barcode, QR, and a bubble grid sized automatically for the question/option count.
Switching to Roster mode swaps the batch-size/starting-ID fields for a roster import — one sheet per row, prefilled with that student's name and ID instead of a sequential number.
Print the generated sheets, run the exam, collect the filled-in sheets. No special paper or hardware required — a laser/inkjet printer and a normal scanner or phone camera are enough.
Upload the answer key and the scanned sheets (folder of images or one PDF), optionally a roster to attach names to the results, and choose where to export.
Click Grade & export and every sheet is graded and written straight to Excel — the same per-student totals appear right in the app as a results table.
The rendered sheet — barcode (identity) top-right, QR (grid metadata) bottom-right, a "how to mark" legend, and a bubble grid that never shrinks below a fillable size:
![]() |
![]() |
| Anonymous — sequential ID only | Roster — prefilled name/ID/class |
And the Excel export — a Summary sheet with one row per student, and a Detail sheet
with one column per question, correct/incorrect colored automatically.
The bubble-classification logic isn't guesswork — it was tuned against a fixture with known marks at known questions, until every mark state below resolved correctly:
Q3 — partial fill (correctly reads as blank), Q9 — crossed out (correctly reads as
blank, not a second vote), Q17 — filled then crossed on a second option (correctly
resolves to one clean answer), Q25 — two options genuinely filled (correctly flagged
AMBIGUOUS, never guessed).
Five built-in themes — Paper (default), Graphite, Meadow, Chalkboard, Dusk —
switchable live from the sidebar. All are generated from one token set + one QSS
template (src/snapgrade/ui/theme.py), so adding a new theme is a new set of color
values, not a new stylesheet. Every widget follows the app's chosen theme only, never
the OS's light/dark setting.
![]() |
![]() |
![]() |
| Paper | Graphite | Meadow |
![]() |
![]() |
|
| Chalkboard | Dusk |
| Concern | Library |
|---|---|
| Desktop UI | PySide6 |
| Icons | qtawesome |
| OMR / barcode decoding | OpenCV, pyzbar |
| Sheet generation (QR/barcode) | qrcode, python-barcode |
| Print-ready PDF export | ReportLab |
| Scanned-PDF rasterization | pypdfium2 |
| Excel import/export | openpyxl |
| Packaging | uv |
uv sync
uv run snapgrade(or uv run python -m snapgrade)
src/snapgrade/
├── app.py # QApplication bootstrap / entry point
├── ui/ # desktop UI — theming, reusable widgets, screens, form-state persistence
├── sheets/ # sheet geometry, specs, mockup renderer, roster import, QR/barcode codecs
└── grading/ # OMR grader, code decoding, bubble classification, scan loading, Excel export
See Architecture for what each package actually does and why it's shaped this way.
UI foundation— theming, reusable widgets, Generate/Grade screen layouts.Sheet geometry + generation— dynamic bubble-grid layout, barcode/QR design, print-ready PDF export.OMR grading core— identity/metadata decoding, ink-ratio bubble classification, validated against a calibration fixture.Excel I/O— roster import (prefill at generation, ID matching at export), correction-matrix import, and a Summary + per-question Detail results export.Wire the UI to the generation/grading engines— Generate and Grade now drive the real pipeline end to end, with live previews, results tables, and error feedback.- Real deskew/boundary-detection preprocessing for actual photographed sheets (today's
ImagePreprocessoris a documented no-op — grading assumes a clean, correctly-sized scan). - Multi-select answers (e.g. exporting
"AB"for one question) — the Detail sheet already renders multiple letters for an ambiguous cell; this would extend it to correction-matrix answers too.
- Usage Guide — the end-to-end workflow, screen by screen.
- Architecture — how the sheet-geometry engine and OMR pipeline actually work, including the real calibration data behind the grading thresholds.












