chromium/ash/webui/camera_app_ui/resources/js/models/barcode.ts

// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {assert, assertInstanceof} from '../assert.js';
import * as Comlink from '../lib/comlink.js';
import {BARCODE_SCAN_INTERVAL} from '../photo_mode_auto_scanner.js';
import * as state from '../state.js';
import {getSanitizedScriptUrl} from '../trusted_script_url_policy_util.js';
import {lazySingleton} from '../util.js';

import {AsyncIntervalRunner} from './async_interval.js';
import {BarcodeWorker} from './barcode_worker.js';

export interface ScanBarcodeResult {
  barcode: DetectedBarcode;
  imageWidth: number;
  imageHeight: number;
}

type BoundingBox = DetectedBarcode['boundingBox'];

// If any dimension of the video exceeds this size, the image would be cropped
// and/or scaled before scanning to speed up the detection.
const MAX_SCAN_SIZE = 720;

// The portion of the square in the middle that would be scanned for barcode.
// TODO(b/172879638): Change 1.0 to match the final UI spec.
const ACTIVE_SCAN_RATIO = 1.0;

const getBarcodeWorker = lazySingleton(
    () => Comlink.wrap<BarcodeWorker>(new Worker(
        getSanitizedScriptUrl('/js/models/barcode_worker.js'),
        {type: 'module'})));

/**
 * A barcode scanner to detect barcodes from a camera stream.
 */
export class BarcodeScanner {
  private scanRunner: AsyncIntervalRunner|null = null;

  /**
   * @param video The video to be scanned for barcode.
   * @param callback The callback for the detected barcodes.
   */
  constructor(
      private readonly video: HTMLVideoElement,
      private readonly callback: (barcode: string) => void) {}

  /**
   * Starts scanning barcodes continuously. Calling this method when it's
   * already started would be no-op.
   *
   * @param scanIntervalMs Scan interval time. Unit is milliseconds.
   */
  start(scanIntervalMs = BARCODE_SCAN_INTERVAL): void {
    if (this.scanRunner !== null) {
      return;
    }
    this.scanRunner = new AsyncIntervalRunner(async (stopped) => {
      // Not show detected code during taking a photo
      if (state.get(state.State.TAKING)) {
        return;
      }

      const result = await this.scan();
      if (!stopped.isSignaled() && result !== null) {
        this.callback(result.barcode.rawValue);
      }
    }, scanIntervalMs);
  }

  stop(): void {
    if (this.scanRunner === null) {
      return;
    }
    this.scanRunner.stop();
    this.scanRunner = null;
  }

  /**
   * Grabs the current video frame for scanning. If the video resolution is too
   * high, the image would be scaled and/or cropped from the center.
   */
  private grabFrameForScan(): Promise<ImageBitmap> {
    const {videoWidth: vw, videoHeight: vh} = this.video;
    if (vw <= MAX_SCAN_SIZE && vh <= MAX_SCAN_SIZE) {
      return createImageBitmap(this.video);
    }

    const scanSize = Math.min(MAX_SCAN_SIZE, vw, vh);
    const ratio = ACTIVE_SCAN_RATIO * Math.min(vw / scanSize, vh / scanSize);
    const sw = ratio * scanSize;
    const sh = ratio * scanSize;
    const sx = (vw - sw) / 2;
    const sy = (vh - sh) / 2;

    // TODO(b/172879638): Figure out why drawing on canvas first is much faster
    // than createImageBitmap() directly.
    const canvas = new OffscreenCanvas(scanSize, scanSize);
    const ctx = assertInstanceof(
        canvas.getContext('2d', {alpha: false}),
        OffscreenCanvasRenderingContext2D);
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';
    ctx.drawImage(this.video, sx, sy, sw, sh, 0, 0, scanSize, scanSize);
    return Promise.resolve(canvas.transferToImageBitmap());
  }

  /**
   * Scans barcodes from the current frame.
   *
   * @return `ScanBarcodeResult` which contains the dimensions of the scanned
   * image and the barcode closest to the center. `null` if nothing is detected.
   */
  async scan(): Promise<ScanBarcodeResult|null> {
    const frame = await this.grabFrameForScan();
    const {width, height} = frame;
    const codes =
        await getBarcodeWorker().detect(Comlink.transfer(frame, [frame]));
    if (codes.length === 0) {
      return null;
    }
    return {
      barcode: getBestBarcode(codes, width, height),
      imageWidth: width,
      imageHeight: height,
    };
  }
}

/**
 * Returns the barcode that is closest to the center of the scanned image.
 */
function getBestBarcode(
    barcodes: DetectedBarcode[], imageWidth: number,
    imageHeight: number): DetectedBarcode {
  assert(barcodes.length > 0);
  let minDistance = Infinity;
  let codeWithMinDistance = barcodes[0];
  for (const code of barcodes) {
    const distance =
        getDistanceToCenter(code.boundingBox, imageWidth, imageHeight);
    if (distance < minDistance) {
      minDistance = distance;
      codeWithMinDistance = code;
    }
  }
  return codeWithMinDistance;
}

function getDistanceToCenter(
    boundingBox: BoundingBox, imageWidth: number, imageHeight: number) {
  const {top, right, bottom, left} = boundingBox;
  const cx = imageWidth / 2;
  const cy = imageHeight / 2;
  const x = (left + right) / 2;
  const y = (top + bottom) / 2;
  const distance = Math.hypot(
      x - cx,
      y - cy,
  );
  return distance;
}