// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import './strings.m.js';
import { BrowserProxy } from './browser_proxy.js';
import {
Status,
type State,
type Box,
type Face,
type Label,
} from './kiosk_vision_internals.mojom-webui.js';
import {
PolymerElement,
} from '//resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import { getTemplate } from './app.html.js';
export interface KioskVisionInternalsAppElement {
$: {
'cameraFeed': HTMLVideoElement,
'overlay': HTMLCanvasElement,
};
}
export class KioskVisionInternalsAppElement extends PolymerElement {
static get is() {
return 'kiosk-vision-internals-app';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
state_: {
type: Object,
observer: 'stateChanged_',
},
};
}
private browserProxy_: BrowserProxy;
private state_: State;
private resizeObserver_: ResizeObserver;
constructor() {
super();
this.browserProxy_ = BrowserProxy.getInstance();
this.browserProxy_.callbackRouter.display.addListener(
(state: State) => { this.state_ = state; });
this.state_ = { status: Status.kUnknown, labels: [], boxes: [], faces: [] };
this.resizeObserver_ = new ResizeObserver(this.resizeCallback_());
}
override ready() {
super.ready();
this.resizeObserver_.observe(this.$.cameraFeed);
}
private statusIs_(state: State, status: keyof typeof Status): boolean {
return state.status === Status[status];
}
private async stateChanged_(state: State) {
if (state.status !== Status.kRunning) {
this.$.cameraFeed.srcObject = null;
return;
}
this.$.cameraFeed.srcObject ??= await getCameraStream();
draw(state, this.$.overlay);
}
private resizeCallback_(): ResizeObserverCallback {
return (entries: ResizeObserverEntry[]) => {
if (entries.length === 0 || entries[0].contentBoxSize.length === 0) {
return;
}
const { inlineSize, blockSize } = entries[0].contentBoxSize[0];
this.$.overlay.width = inlineSize;
this.$.overlay.height = blockSize;
this.stateChanged_(this.state_);
}
}
}
declare global {
interface HTMLElementTagNameMap {
'kiosk-vision-internals-app': KioskVisionInternalsAppElement;
}
}
customElements.define(
KioskVisionInternalsAppElement.is, KioskVisionInternalsAppElement);
function draw(state: State, overlay: HTMLCanvasElement) {
const ctx = overlay.getContext('2d');
if (ctx == null) {
return console.error('overlay.getContext is null');
}
ctx.clearRect(0, 0, overlay.width, overlay.height);
for (const box of state.boxes) { drawBox(ctx, overlay, box); }
for (const face of state.faces) { drawFace(ctx, overlay, face); }
for (const label of state.labels) { drawLabel(ctx, overlay, label); }
}
const RED = "#f87171";
const GREEN = "#a3e635";
const WHITE = "#e2e8f0";
function drawBox(
ctx: CanvasRenderingContext2D,
overlay: HTMLCanvasElement,
box: Box,
color: string = RED,
) {
const { x, y } = toCanvasCoordinates(overlay, box.x, box.y);
const { x: width, y: height } =
toCanvasCoordinates(overlay, box.width, box.height);
ctx.beginPath();
ctx.rect(x, y, width, height);
ctx.lineWidth = 4;
ctx.strokeStyle = color;
ctx.stroke();
ctx.closePath();
}
function drawLabel(
ctx: CanvasRenderingContext2D,
overlay: HTMLCanvasElement,
label: Label,
) {
const MARGIN = 7, PADDING = 8;
const { x, y } = toCanvasCoordinates(overlay, label.x, label.y);
const text = `#${label.id}`;
ctx.beginPath();
ctx.fillStyle = RED;
ctx.font = "18px Roboto";
ctx.textBaseline = "alphabetic";
const { width, actualBoundingBoxAscent: height } = ctx.measureText(text);
// Draw a background box behind the label text.
ctx.roundRect(
x,
y - MARGIN - height - 2 * PADDING,
width + 2 * PADDING,
height + 2 * PADDING,
[6]
);
ctx.fill();
// Draw the actual label text on top of the box.
ctx.fillStyle = WHITE;
ctx.fillText(text, x + PADDING, y - MARGIN - PADDING);
ctx.closePath();
}
function drawFace(
ctx: CanvasRenderingContext2D,
overlay: HTMLCanvasElement,
face: Face,
) {
// Draw a green box if the person is looking at the camera.
if (isLookingAtTheCamera(face)) {
return drawBox(ctx, overlay, face.box, GREEN);
}
// Draw a red box and an arrow if the person is not looking at the camera.
drawBox(ctx, overlay, face.box);
const { x: centerX, y: centerY } = boxCenter(face.box);
const { x: x0, y: y0 } = toCanvasCoordinates(overlay, centerX, centerY);
const { x: x1, y: y1 } =
toCanvasCoordinates(overlay, centerX + face.pan, centerY - face.tilt);
const { arrowX0, arrowY0, arrowX1, arrowY1 } = arrowHeadEnds(x0, y0, x1, y1);
ctx.beginPath();
ctx.strokeStyle = RED;
ctx.lineWidth = 4;
// Draw the arrow body.
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
// Draw the arrow head.
ctx.lineTo(arrowX0, arrowY0);
ctx.moveTo(x1, y1);
ctx.lineTo(arrowX1, arrowY1);
ctx.stroke();
ctx.closePath();
}
function boxCenter({ x, y, width, height }: Box): { x: number, y: number } {
return { x: x + width / 2, y: y + height / 2 };
}
function arrowHeadEnds(x0: number, y0: number, x1: number, y1: number)
: { arrowX0: number, arrowY0: number, arrowX1: number, arrowY1: number } {
const HEAD_LENGTH = 10, THIRTY_DEGREES = Math.PI / 6;
const dx = x1 - x0, dy = y1 - y0;
const angle = Math.atan2(dy, dx);
return {
arrowX0: x1 - HEAD_LENGTH * Math.cos(angle - THIRTY_DEGREES),
arrowY0: y1 - HEAD_LENGTH * Math.sin(angle - THIRTY_DEGREES),
arrowX1: x1 - HEAD_LENGTH * Math.cos(angle + THIRTY_DEGREES),
arrowY1: y1 - HEAD_LENGTH * Math.sin(angle + THIRTY_DEGREES),
};
}
function getCameraStream(): Promise<MediaStream | null> {
return navigator.mediaDevices.getUserMedia({
video: { aspectRatio: 4 / 3 },
audio: false,
}).catch((error) => {
console.error('Failed to get camera stream:', error);
return null;
});
}
// Chrome emits Box dimensions on a 569x320 grid. This maps dimensions to
// `canvas.width` x `canvas.height` sizes.
function toCanvasCoordinates(
canvas: HTMLCanvasElement,
x: number,
y: number,
): { x: number, y: number } {
const FRAME_WIDTH = 569;
const FRAME_HEIGHT = 320;
return {
x: scaleCoordinate(x, FRAME_WIDTH, canvas.width),
y: scaleCoordinate(y, FRAME_HEIGHT, canvas.height),
}
}
function scaleCoordinate(x: number, currentMax: number, newMax: number) {
return x / currentMax * newMax;
}
function isLookingAtTheCamera(face: Face): boolean {
const HORIZONTAL_THRESHOLD = 10;
const VERTICAL_THRESHOLD = 6;
return Math.abs(face.pan) < HORIZONTAL_THRESHOLD
&& Math.abs(face.tilt) < VERTICAL_THRESHOLD;
}