Skip to content

ML detection (optional)

Scanic's default detector is the classical Canny and contour pipeline. It is small, fast, and needs no extra dependencies, and it handles clean documents well. On harder photos (cluttered backgrounds, low contrast, or strong perspective) it can miss the document. For those cases you can switch to a neural detector that is more robust, at the cost of a one time download of about 2 MB the first time it runs.

It is opt in

You enable the ML detector per call with detector: 'ml'. If your app never passes that option, none of the ML code or assets are loaded, so it adds nothing to what your users download. Your default scanning path stays exactly as it is.

Installation

bash
npm install scanic

There is no separate package to install. The ONNX Runtime JavaScript API is bundled inside scanic as a lazy chunk (about 50 KB, roughly 15 KB gzipped) that loads only the first time you call detector: 'ml'. The model and the WebAssembly runtime are fetched from a CDN on that first call, so you do not pin or manage any runtime version yourself.

Basic usage

js
import { scanDocument } from 'scanic';

const result = await scanDocument(image, { detector: 'ml' });

if (result.success) {
  console.log(result.corners); // { topLeft, topRight, bottomRight, bottomLeft }
  console.log(result.score);   // P(document present), 0 to 1
}

extract mode works the same way. The detected corners feed the same perspective warp:

js
const { output } = await scanDocument(image, { detector: 'ml', mode: 'extract' });

Using the UMD or <script> build

The bundled runtime applies to the ESM build (the import path). If you load scanic through require('scanic') or a <script> tag and want ML, add onnxruntime-web@1.23.x to your page yourself. It stays external in that build because the UMD format cannot split code into separate chunks.

Warming up

The first ML scan loads the runtime and model (about 2 MB). To avoid that delay on the first user action, warm it up ahead of time with the Scanner class:

js
import { Scanner } from 'scanic';

const scanner = new Scanner({ detector: 'ml' });
await scanner.initialize();   // fetches and compiles the wasm and model
const result = await scanner.scan(image);

Options

All ML options live under options.ml:

OptionTypeDefaultDescription
assetBaseUrlstringjsDelivr scanic-mlBase URL that serves the .wasm and .ort assets. Set this to self host.
modelUrlstring${assetBaseUrl}doccornernet_lean.ortExplicit model URL.
wasmPathsstringassetBaseUrlDirectory for the ORT wasm and loader.
modelBytesUint8Array(none)Pre fetched model bytes, which skips the network request.
threadedbooleanfalseShorthand for numThreads: 4. See Threads.
numThreadsnumber1, or 4 when threaded: trueORT thread count. Values above 1 need COOP and COEP headers (see below).
minScorenumber0.5Minimum P(document) for success to be true.

Self hosting (offline or no CDN)

If you cannot rely on the CDN, install the companion scanic-ml package, serve its dist/ folder from your own origin, and point scanic at it:

js
await scanDocument(image, {
  detector: 'ml',
  ml: { assetBaseUrl: '/assets/scanic-ml/' }
});

Threads (advanced)

scanic ships one wasm build, compiled with pthread support, so it works everywhere and can also run on more than 1 thread when the host allows it:

  • Default (threaded unset or false): 1 thread, about 13 ms of model inference per scan, works on any page with no special headers.
  • ml: { threaded: true }: about 2x faster inference (roughly 6 to 7 ms at 4 threads in a cross-origin-isolated browser), same assets. Needs the host page to be cross origin isolated (COOP: same-origin and COEP: require-corp response headers) for SharedArrayBuffer to be available; without that it falls back to running on 1 thread automatically, so it's safe to request speculatively. Defaults to 4 threads, override with numThreads. The speedup is on the model inference step. A full scan also includes single-threaded image preprocessing, so the end-to-end gain is smaller for a one-off scan.
js
await scanDocument(image, {
  detector: 'ml',
  ml: { threaded: true }
});

Most apps do not need this — only reach for it if you already control the response headers of the page hosting scanic (e.g. an internal tool or an app you fully control the server for).

Classical or ML: which to use

Classical (default)ML (detector: 'ml')
Extra downloadnoneabout 15 KB gzipped JS, then about 2 MB of assets on first use
Dependenciesnonenone. The runtime is bundled, assets come from a CDN
Latency3 to 10 msabout 10 ms (single thread)
Clean documentsexcellentexcellent
Cluttered, low contrast, or skewedcan missmore robust

A good default is to run classical first and fall back to ML when the classical result is missing or low confidence, or to use ML directly for camera scenes you know are messy.

How it stays small and fast

The ML detector is deliberately small. The model is a channel slimmed SimCC network (DocCornerNet) that treats each corner coordinate as a 1D classification over pixel bins, then takes a soft argmax. At 456K parameters it reaches a median error around 2 to 3 px, sub pixel on clean documents, and is faster and smaller than the larger baseline it replaced. All of this is plain fp32. Quantization was tried and made things worse: INT8 roughly doubled WASM latency because the per operator overhead on such a small model costs more than it saves.

The runtime is the part that is usually large. Stock onnxruntime-web ships 13 to 26 MB of WASM, which would dwarf scanic. Other options were measured rather than guessed:

Approachwasm sizelatency (single thread)result
stock onnxruntime-web13 MBabout 10 mstoo large
WebGPU backendn/aabout 358 ms35x slower, per dispatch overhead on a small graph
tract (pure Rust ONNX to WASM)4.1 MBabout 104 ms10x slower, no MLAS class kernels
hand written WASM kernelsabout 0.5 MBabout 100 ms (projected)slower and weeks of work
custom minimal ORT build1.5 MBabout 11 mschosen

ONNX Runtime can be compiled for the web with only the operators a specific model uses, dropping the ONNX parser, RTTI, and exception handling, while keeping the same MLAS SIMD kernels. This model needs about 18 operators. The result is a 1.5 MB wasm (about 527 KB gzipped), roughly 88 percent smaller than stock, with the same single thread latency as stock ORT and output that matches it to within about 0.00004. The build is scripted and reproducible in scanic-ml/build/.

The net effect for you: a single npm install scanic, nothing extra for users who do not use ML, and about 2 MB of lazy assets for users who do, at full ORT speed and accuracy.

Released under the MIT License.