npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@eviontech/omr-reader

v0.1.1

Published

Automated OMR sheet detection and reading — auto-detects any format, returns structured JSON

Readme

@eviontech/omr-reader

npm version license types

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:example

Opens 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 composableuseOmrReader() with reactive state
  • TypeScript-first — full types, Zod runtime validation

Installation

npm install @eviontech/omr-reader

Peer dependency (optional, only needed for useOmrReader()):

npm install vue

@techstark/opencv-js is 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. Call initializeOpenCV() 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 layout

new 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 invalid

Vue 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 exceeds fillThreshold
  • blank — no bubble exceeds fillThreshold
  • multiple — more than one bubble exceeds fillThreshold; answer is 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:

  1. Square — filled solid squares (default for most OMR sheets)
  2. Circle — filled circles (HoughCircles-based)
  3. 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 JSON

Development

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:5173

The 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.