All files / qr-scanner/src decodeImage.ts

100% Statements 23/23
100% Branches 17/17
100% Functions 4/4
100% Lines 19/19

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126                                    676x             169x                             190x   190x 169x 169x 169x               169x           3x         188x                                             179x   178x           179x 179x   179x             179x             13x       12x 12x                
import { QRDecodeError } from "./errors";
import { binarize } from "./image/binarize";
import type { BitMatrix } from "./image/bitmatrix";
import { toGray } from "./image/luminance";
import { decodeMatrix } from "./decode/decodeMatrix";
import { detectSymbols, type DetectedSymbol } from "./detect/detect";
import type { GrayImage, QRScanOptions, QRScanResult, Quad } from "./types";
 
/**
 * The pure, DOM-free decode path: `ImageData` → results.
 *
 * Everything above this (camera frames, files, the native engine, the worker)
 * eventually calls `decodeImageData`. Keeping it free of DOM APIs is what lets
 * the same code run in a worker, in a test, and in a server-side script.
 */
 
/** Map a point from working-image pixels back to source-image pixels. */
function toSource(gray: GrayImage, point: { x: number; y: number }): { x: number; y: number } {
  return {
    x: gray.offsetX + point.x / gray.scale,
    y: gray.offsetY + point.y / gray.scale,
  };
}
 
function cornersToSource(gray: GrayImage, corners: Quad): Quad {
  return [
    toSource(gray, corners[0]),
    toSource(gray, corners[1]),
    toSource(gray, corners[2]),
    toSource(gray, corners[3]),
  ];
}
 
function decodeDetected(
  symbols: readonly DetectedSymbol[],
  gray: GrayImage,
  options: QRScanOptions,
  inverted: boolean,
  timestamp: number | undefined,
): QRScanResult[] {
  const results: QRScanResult[] = [];
 
  for (const symbol of symbols) {
    for (const attempt of symbol.attempts) {
      try {
        results.push(
          decodeMatrix(attempt.grid, {
            corners: cornersToSource(gray, attempt.corners),
            inverted,
            binary: options.binary,
            timestamp,
          }),
        );
        break; // this symbol is read; the remaining attempts are alternatives
      } catch (error) {
        // A failed attempt is ordinary — most frames contain no code at all,
        // and a symbol usually has several candidate readings of which only one
        // works. Anything that is *not* a decode failure is a bug and must
        // travel to the caller rather than be reported as "nothing found".
        if (!(error instanceof QRDecodeError)) throw error;
      }
    }
  }
 
  return results;
}
 
export interface DecodeImageDataOptions extends QRScanOptions {
  /** Overrides `performance.now()` — the frame pump passes the capture time. */
  timestamp?: number;
  /** A luminance buffer to reuse across frames. */
  buffer?: Uint8ClampedArray;
}
 
/**
 * Decode QR symbols from raw pixels.
 *
 * @returns Every symbol that decoded, up to `maxSymbols`. An empty array means
 *   nothing was found — the normal outcome for most camera frames, and not an
 *   error.
 */
export function decodeImageData(
  image: ImageData,
  options: DecodeImageDataOptions = {},
): QRScanResult[] {
  // An empty frame is nothing to find, not an error — and it must not be
  // rounded up into a 1 × 1 image by the downscaler on its way through.
  if (image.width < 1 || image.height < 1) return [];
 
  const gray = toGray(image, {
    maxDimension: options.maxDimension ?? 800,
    region: options.region,
    buffer: options.buffer,
  });
 
  const bits = binarize(gray, options.binarizer);
  const maxSymbols = Math.max(1, options.maxSymbols ?? 1);
 
  const direct = decodeDetected(
    detectSymbols(bits, { maxSymbols, sampling: options.sampling }),
    gray,
    options,
    false,
    options.timestamp,
  );
  if (direct.length > 0) return direct;
 
  // The inverted pass costs nothing on a frame that already decoded, so `auto`
  // simply means "try it when the normal reading found nothing". Pacing it
  // across frames is the camera loop's job (it owns the frame counter); a pure
  // function keeping hidden per-call state would be both surprising and wrong
  // the moment two decodes overlap.
  if ((options.invert ?? "auto") === "never") return [];
 
  // Inverting the *bitmap* rather than re-thresholding keeps the expensive half
  // of the pipeline — grayscale conversion and binarization — to one run.
  const flipped: BitMatrix = bits.inverted();
  return decodeDetected(
    detectSymbols(flipped, { maxSymbols, sampling: options.sampling }),
    gray,
    options,
    true,
    options.timestamp,
  );
}