@eviontech/omr-reader
v0.1.1
Published
Automated OMR sheet detection and reading — auto-detects any format, returns structured JSON
Maintainers
Readme
@eviontech/omr-reader
OMR (Optical Mark Recognition) sheet detection and reading. Built on OpenCV.js (WebAssembly) — zero native dependencies, no server required.
Works in the browser (File/Blob/canvas inputs) and in Node.js (raw RGBA pixel inputs). Designed for Vue 3 SPAs but framework-agnostic.
Browser: WebAssembly + Canvas API (all modern browsers since 2017). Node.js: v18+.
Running the Demo
# 1. Clone / navigate to the repo
cd omr-reader
# 2. Install dependencies
npm install
# 3. Start the interactive test app
npm run dev:exampleOpens http://localhost:5173 in your browser automatically.
The demo app (examples/App.vue) has 5 tabs that cover every feature:
| Tab | What it tests |
|-----|--------------|
| 1 · Auto-Detect Scan | Pick any OMR image — scans with full auto-detection, shows debug canvas |
| 2 · useOmrReader() | Same scan via the Vue 3 composable with live reactive state |
| 3 · Generate Layout | Scan a sample sheet → download layout.json for batch reuse |
| 4 · Scan with Layout | Load a saved layout + scan — faster and more accurate |
| 5 · Validate Layout | Paste JSON and see full Zod validation errors |
No sample image? Any photograph of a printed OMR/bubble sheet works. Make sure all 4 corner markers are visible and the sheet is reasonably lit.
Features
- Auto-detects any OMR sheet format — no pre-configuration needed
- Perspective correction — handles skewed / photographed sheets
- Corner marker detection — square, circle, and cross/plus markers
- Roll number reading — 10-row × N-column digit grid (0–9)
- Answer bubble reading — variable questions × options (A/B/C/D/E…)
- Layout generator — scan once, reuse forever for faster batch scanning
- Vue 3 composable —
useOmrReader()with reactive state - TypeScript-first — full types, Zod runtime validation
Installation
npm install @eviontech/omr-readerPeer dependency (optional, only needed for useOmrReader()):
npm install vue
@techstark/opencv-jsis a direct dependency — it installs automatically. The WASM binary (~8 MB) is kept external from the bundle so your bundler resolves it; no extra setup is needed. CallinitializeOpenCV()once before scanning.
Quick Start
Vanilla (auto-detect)
import { initializeOpenCV, scanSheet } from '@eviontech/omr-reader';
// One-time init (safe to call multiple times — idempotent)
await initializeOpenCV();
const input = document.querySelector('input[type=file]') as HTMLInputElement;
input.addEventListener('change', async () => {
const file = input.files?.[0];
if (!file) return;
const result = await scanSheet(file);
console.log(result.rollNumber); // "123456"
console.log(result.answers); // [{ questionIndex: 1, answer: 'B', ... }, ...]
console.log(result.summary); // { totalQuestions: 40, answered: 38, blank: 2, multipleMarked: 0 }
});Node.js
OpenCV.js runs fine in Node — only image decoding differs. Decode to raw
RGBA pixels with any library (e.g. sharp)
and pass a { data, width, height } object:
import sharp from 'sharp';
import { initializeOpenCV, scanSheet } from '@eviontech/omr-reader';
await initializeOpenCV();
const { data, info } = await sharp('sheet.jpg')
.ensureAlpha() // RGBA, 4 channels
.raw()
.toBuffer({ resolveWithObject: true });
const result = await scanSheet({
data: new Uint8ClampedArray(data),
width: info.width,
height: info.height,
});
console.log(result.rollNumber, result.summary);Vue 3 Composable
<script setup lang="ts">
import { useOmrReader } from '@eviontech/omr-reader/vue';
const { isReady, isScanning, result, error, scan } = useOmrReader();
const onFile = (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (file) scan(file);
};
</script>
<template>
<div>
<p>OpenCV: {{ isReady ? 'Ready' : 'Loading…' }}</p>
<input type="file" accept="image/*" :disabled="!isReady" @change="onFile" />
<p v-if="isScanning">Scanning…</p>
<p v-if="error" style="color:red">{{ error }}</p>
<pre v-if="result">{{ JSON.stringify(result, null, 2) }}</pre>
</div>
</template>Workflow: Layout Generator (Recommended for Batch Scanning)
For batch scanning many sheets of the same format, generate a layout once from a sample image and reuse it. This is faster and more accurate than auto-detecting from scratch on every scan.
import { generateLayout, scanSheetWithLayout } from '@eviontech/omr-reader';
// Step 1 — Generate layout from a sample sheet (once per sheet format)
const sampleFile = /* File from <input type="file"> */;
const layout = await generateLayout(sampleFile, {
fillThreshold: 0.35, // optional: override default fill sensitivity
notes: 'Grade 10 Science 2025', // optional: embed metadata
});
// Step 2 — Serialise and save for reuse (optional)
const layoutJson = JSON.stringify(layout);
localStorage.setItem('omr-layout', layoutJson);
// Step 3 — Reload and scan many sheets
const savedLayout = JSON.parse(localStorage.getItem('omr-layout')!);
const result = await scanSheetWithLayout(studentFile, savedLayout);API Reference
Initialisation
initializeOpenCV(): Promise<void>
Initialises the OpenCV WASM runtime. Must be called before any scanning. Safe to call multiple times — subsequent calls return the same cached promise.
import { initializeOpenCV } from '@eviontech/omr-reader';
await initializeOpenCV();Scanning
scanSheet(input: ImageInput): Promise<ScanResult>
Convenience function. Scans a sheet using fully automatic layout detection.
const result = await scanSheet(file);scanSheetWithLayout(input: ImageInput, layout: Layout): Promise<ScanResult>
Convenience function. Scans using a pre-generated layout for higher accuracy and speed.
const result = await scanSheetWithLayout(file, layout);debugScanSheet(input: ImageInput, options?: { optionsPerQuestion?: number }): Promise<DebugScanData>
Runs the full pipeline and returns intermediate debug data: detected bubbles, grid layout, region boundaries, and image previews. Useful for diagnosing detection or clustering issues.
import { debugScanSheet } from '@eviontech/omr-reader';
const debug = await debugScanSheet(file);
console.log(debug.detectedBubbles); // all candidate bubbles found
console.log(debug.grid); // rows × columns layoutnew OmrScanner(options?: ScanOptions)
Class-based API for more control. Caches nothing between calls — create one per scan or reuse.
const scanner = new OmrScanner({ layout, fillThreshold: 0.40 });
const result = await scanner.scan(file);OmrScanner.initialize(): Promise<void>
Static method — same as initializeOpenCV(). Called automatically by scan().
Layout
generateLayout(input: ImageInput, options?: GenerateLayoutOptions): Promise<Layout>
Scans a sample sheet and produces a reusable Layout object describing the bubble positions.
const layout = await generateLayout(sampleFile, { fillThreshold: 0.35, notes: 'optional note' });
// Persist it:
const json = JSON.stringify(layout);parseLayout(data: unknown): Layout
Parse and validate a Layout from any value (e.g. a fetch() JSON response).
Throws ZodError if invalid.
const resp = await fetch('/api/layouts/grade10.json');
const layout = parseLayout(await resp.json());parseLayoutFromJSON(json: string): Layout
Parse and validate a Layout from a raw JSON string.
const layout = parseLayoutFromJSON(localStorage.getItem('layout')!);loadLayoutFromFile(file: File): Promise<Layout>
Load a Layout from a .json file (e.g. from <input type="file">).
const layout = await loadLayoutFromFile(jsonFile);validateLayout(data: unknown): ValidationResult
Non-throwing validation. Returns a discriminated union.
const result = validateLayout(someData);
if (result.success) {
console.log(result.layout);
} else {
console.log(result.error.issues);
}assertValidLayout(data: unknown): Layout
Throwing validation. Returns the layout or throws a formatted error.
const layout = assertValidLayout(someData); // throws if invalidVue Composable
useOmrReader(options?: ScanOptions)
Imported from the omr-reader/vue subpath (keeps the main entry free of
the optional vue dependency):
import { useOmrReader } from '@eviontech/omr-reader/vue';
const {
isReady, // Ref<boolean> — true when OpenCV WASM is loaded
isScanning, // Ref<boolean> — true while a scan is in progress
result, // ShallowRef<ScanResult | null>
error, // Ref<string | null>
scan, // (input: ImageInput) => Promise<void>
} = useOmrReader(options);Starts OpenCV initialisation immediately on composable creation.
Call scan(file) when the user picks a file — it handles all state automatically.
Example with fill threshold:
const { isReady, isScanning, result, error, scan } = useOmrReader({
fillThreshold: 0.40,
});Example with pre-loaded layout:
const layout = parseLayoutFromJSON(savedJson);
const { scan, result } = useOmrReader({ layout });Types
ImageInput
type ImageInput =
| File
| Blob
| HTMLImageElement
| HTMLCanvasElement
| ImageData
| RawImage;
interface RawImage {
data: Uint8ClampedArray | Uint8Array; // tightly-packed RGBA
width: number;
height: number;
}Accepted by all scan functions. File/Blob/DOM element inputs are
browser-only; ImageData/RawImage work everywhere, including Node.js.
ScanOptions
interface ScanOptions {
/** Pre-generated layout for higher accuracy and speed */
layout?: Layout;
/** Fill ratio threshold, 0–1. Default: 0.35 */
fillThreshold?: number;
/** 'auto-detect' | 'layout' — informational, does not change behaviour */
mode?: 'auto-detect' | 'layout';
/** Canvas to draw debug overlay onto after scanning */
debugCanvas?: HTMLCanvasElement;
}GenerateLayoutOptions
interface GenerateLayoutOptions {
/** Override fill threshold embedded in the layout. Default: 0.35 */
fillThreshold?: number;
/** Optional human-readable note embedded in the layout JSON */
notes?: string;
}ScanResult
interface ScanResult {
meta: ScanMeta;
rollNumber: string; // e.g. "123456". '?' for unread digits.
answers: QuestionResult[];
summary: ScanSummary;
}ScanMeta
interface ScanMeta {
scannedAt: string; // ISO 8601
imagePath: string; // file name or 'browser-input'
processingTimeMs: number;
imageSize: { width: number; height: number };
layoutSource: 'auto-detect' | 'layout.json';
}QuestionResult
interface QuestionResult {
questionIndex: number; // 1-based
answer: string | null; // 'A' | 'B' | 'C' | 'D' | 'E' | null
confidence: number; // fill ratio of the selected bubble, 0–1
fillRatios: FillRatioEntry[];
status: 'ok' | 'blank' | 'multiple';
}
interface FillRatioEntry {
option: string; // 'A', 'B', 'C', …
fillRatio: number; // 0.0–1.0
}ok— exactly one bubble exceedsfillThresholdblank— no bubble exceedsfillThresholdmultiple— more than one bubble exceedsfillThreshold;answeris set to the most-filled one
ScanSummary
interface ScanSummary {
totalQuestions: number;
answered: number;
blank: number;
multipleMarked: number;
}Layout
The full layout schema (Zod-validated). Key fields:
interface Layout {
version: '1';
generatedAt: string; // ISO 8601
generatedFrom: string; // file name or 'browser-input'
imageSize: { width: number; height: number };
cornerMarkers: {
markerType: 'square' | 'circle' | 'cross';
positions: [Point, Point, Point, Point]; // [TL, TR, BL, BR] normalised 0–1
};
rollNumber: {
boundingRect: Rect;
numDigits: number; // 1–20
bubbles: Bubble[][]; // [10 rows (0-9)][digit positions]
};
answerGrid: {
boundingRect: Rect;
numQuestions: number;
optionsPerQuestion: number; // 2–10
bubbles: Bubble[][]; // [questions][options]
};
fillThreshold: number; // 0–1, default 0.35
notes?: string;
}
interface Bubble {
center: { x: number; y: number };
rect: { x: number; y: number; width: number; height: number };
}ValidationResult
type ValidationResult =
| { success: true; layout: Layout }
| { success: false; error: ZodError };Tuning Guide
Fill Threshold (fillThreshold)
The key sensitivity parameter. A bubble is considered filled if the mean pixel intensity of its inner region exceeds fillThreshold × 255.
| Scenario | Recommended value | |---|---| | Dark, confident pen marks | 0.30 – 0.35 (default) | | Pencil marks, light ink | 0.20 – 0.30 | | Reduce false positives | 0.40 – 0.50 | | Pre-printed bubbles are slightly dark | 0.50+ |
Image Quality
- Minimum recommended resolution: 1000 × 1400 px
- Images are automatically downscaled to max 2000 × 2800 px
- Works with JPEG, PNG, WebP (any format accepted by
createImageBitmap) - Slightly tilted sheets are fine — perspective correction handles up to ~45° skew
- Ensure all 4 corner markers are visible
Corner Markers
Detected automatically in this priority order:
- Square — filled solid squares (default for most OMR sheets)
- Circle — filled circles (HoughCircles-based)
- Cross / Plus — cross-shaped markers
Pipeline Overview
ImageInput (File / Blob / HTMLImageElement / HTMLCanvasElement)
│
▼
loadImage() — Canvas API → RGBA cv.Mat
│
▼
preprocess() — Grayscale → Gaussian blur → Adaptive threshold → Morphological open
│
▼
detectCorners() — Contour analysis for square/circle/cross markers
│ Returns [topLeft, topRight, bottomLeft, bottomRight]
▼
warpSheet() — getPerspectiveTransform + warpPerspective
│
▼
detectLayout() OR — Projection histogram clustering → rows × cols bubble grid
use saved Layout — (skip detection if layout provided)
│
▼
binarizeForFill() — Grayscale → Gaussian blur → global Otsu threshold
│ (an adaptive threshold would hollow out solid marks)
▼
readRollNumber() — Fill ratio per column → digit 0–9 per position
readAnswers() — Fill ratio per row → answer per question
│
▼
buildResult() — Assemble ScanResult JSONDevelopment
npm run build # produces dist/ (ESM + CJS + .d.ts)
npm run typecheck # tsc --noEmit
npm test # all tests (vitest unit tests + Node-based OpenCV tests)
npm run test:unit # pure unit tests only (vitest)
npm run test:cv # OpenCV WASM tests, incl. full-pipeline integration (node:test via tsx)
npm run test:coverage # coverage report
npm run dev:example # start the interactive demo at http://localhost:5173The OpenCV-dependent tests run under Node's native test runner instead of vitest — the ~11 MB opencv.js bundle hangs vitest's module transformer. The integration tests render a synthetic OMR sheet with OpenCV drawing calls and run the full pipeline on it, so no image fixtures are needed.
Changelog
See CHANGELOG.md.
License
MIT — see LICENSE.
