// 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;
}