UNPKG

tfjs-tiny-yolov2

Version:

Tiny YOLO v2 object detection with tensorflow.js.

379 lines (289 loc) 12.7 kB
import * as tf from '@tensorflow/tfjs-core'; import { BoundingBox, Dimensions, IDimensions, iou, Point, Rect } from 'tfjs-image-recognition-base'; import { TinyYolov2TrainableConfig } from './config'; import { CELL_SIZE } from './const'; import { GridPosition, GroundTruth, GroundTruthWithGridPosition } from './types'; export class TinyYolov2LossFunction { private _config: TinyYolov2TrainableConfig private _reshapedImgDims: Dimensions private _outputTensor: tf.Tensor4D private _groundTruth: GroundTruthWithGridPosition[] private _predictedBoxes: GroundTruthWithGridPosition[] public noObjectLossMask: tf.Tensor4D public objectLossMask: tf.Tensor4D public coordBoxOffsetMask: tf.Tensor4D public coordBoxSizeMask: tf.Tensor4D public groundTruthClassScoresMask: tf.Tensor4D constructor( outputTensor: tf.Tensor4D, groundTruth: GroundTruth[], predictedBoxes: GroundTruthWithGridPosition[], reshapedImgDims: IDimensions, config: TinyYolov2TrainableConfig ) { this._config = config this._reshapedImgDims = new Dimensions(reshapedImgDims.width, reshapedImgDims.height) this._outputTensor = outputTensor this._predictedBoxes = predictedBoxes this.validateGroundTruthBoxes(groundTruth) this._groundTruth = this.assignGroundTruthToAnchors(groundTruth) const groundTruthMask = this.createGroundTruthMask() const { coordBoxOffsetMask, coordBoxSizeMask, scoreMask } = this.createCoordAndScoreMasks() this.noObjectLossMask = tf.tidy(() => tf.mul(scoreMask, tf.sub(tf.scalar(1), groundTruthMask))) as tf.Tensor4D this.objectLossMask = tf.tidy(() => tf.mul(scoreMask, groundTruthMask)) as tf.Tensor4D this.coordBoxOffsetMask = tf.tidy(() => tf.mul(coordBoxOffsetMask, groundTruthMask)) as tf.Tensor4D this.coordBoxSizeMask = tf.tidy(() => tf.mul(coordBoxSizeMask, groundTruthMask)) as tf.Tensor4D const classScoresMask = tf.tidy(() => tf.sub(tf.scalar(1), coordBoxOffsetMask.add(coordBoxSizeMask).add(scoreMask))) this.groundTruthClassScoresMask = tf.tidy(() => tf.mul(classScoresMask, groundTruthMask)) as tf.Tensor4D } public get config(): TinyYolov2TrainableConfig { return this._config } public get reshapedImgDims(): Dimensions { return this._reshapedImgDims } public get outputTensor(): tf.Tensor4D { return this._outputTensor } public get groundTruth(): GroundTruthWithGridPosition[] { return this._groundTruth } public get predictedBoxes(): GroundTruthWithGridPosition[] { return this._predictedBoxes } public get inputSize(): number { return Math.max(this.reshapedImgDims.width, this.reshapedImgDims.height) } public get withClassScores(): boolean { return this._config.withClassScores || this._config.classes.length > 1 } public get boxEncodingSize(): number { return 5 + (this.withClassScores ? this._config.classes.length : 0) } public get anchors(): Point[] { return this._config.anchors } public get numBoxes(): number { return this.anchors.length } public get numCells(): number { return this.inputSize / CELL_SIZE } public get gridCellEncodingSize(): number { return this.boxEncodingSize * this.numBoxes } public toOutputTensorShape(tensor: tf.Tensor) { return tf.tidy(() => tensor.reshape([1, this.numCells, this.numCells, this.gridCellEncodingSize])) } public computeLoss() { return tf.tidy(() => { const noObjectLoss = this.computeNoObjectLoss() const objectLoss = this.computeObjectLoss() const coordLoss = this.computeCoordLoss() const classLoss = this.withClassScores ? this.computeClassLoss() : tf.scalar(0) const totalLoss: tf.Tensor<tf.Rank.R0> = tf.tidy(() => noObjectLoss.add(objectLoss).add(coordLoss).add(classLoss)) return { noObjectLoss, objectLoss, coordLoss, classLoss, totalLoss } }) } public computeNoObjectLoss(): tf.Tensor<tf.Rank.R0> { return tf.tidy(() => this.computeLossTerm( this.config.noObjectScale, this.toOutputTensorShape(this.noObjectLossMask), tf.sigmoid(this.outputTensor) ) ) } public computeObjectLoss(): tf.Tensor<tf.Rank.R0> { return tf.tidy(() => this.computeLossTerm( this.config.objectScale, this.toOutputTensorShape(this.objectLossMask), tf.sub(this.toOutputTensorShape(this.computeIous()), tf.sigmoid(this.outputTensor)) ) ) } public computeClassLoss(): tf.Tensor<tf.Rank.R0> { return tf.tidy(() => { const classLossTensor = tf.tidy(() => { const predClassScores = tf.mul( tf.softmax(this.outputTensor.reshape([this.numCells, this.numCells, this.numBoxes, this.boxEncodingSize]), 3), this.groundTruthClassScoresMask ) const gtClassScores = this.createOneHotClassScoreMask() return tf.sub(gtClassScores, predClassScores) }) return this.computeLossTerm( this.config.classScale, tf.scalar(1), classLossTensor as tf.Tensor4D ) }) } public computeCoordLoss(): tf.Tensor<tf.Rank.R0> { return tf.tidy(() => this.computeLossTerm( this.config.coordScale, tf.scalar(1), tf.add(this.computeCoordBoxOffsetError(), this.computeCoordBoxSizeError()) ) ) } public computeCoordBoxOffsetError(): tf.Tensor4D { return tf.tidy(() => { const mask = this.toOutputTensorShape(this.coordBoxOffsetMask) const gtBoxOffsets = tf.mul(mask, this.toOutputTensorShape(this.computeCoordBoxOffsets())) const predBoxOffsets = tf.mul(mask, tf.sigmoid(this.outputTensor)) return tf.sub(gtBoxOffsets, predBoxOffsets) }) } public computeCoordBoxSizeError(): tf.Tensor4D { return tf.tidy(() => { const mask = this.toOutputTensorShape(this.coordBoxSizeMask) const gtBoxSizes = tf.mul(mask, this.toOutputTensorShape(this.computeCoordBoxSizes())) const predBoxSizes = tf.mul(mask, this.outputTensor) return tf.sub(gtBoxSizes, predBoxSizes) }) } private computeLossTerm(scale: number, mask: tf.Tensor<tf.Rank>, lossTensor: tf.Tensor4D): tf.Tensor<tf.Rank.R0> { return tf.tidy(() => tf.mul(tf.scalar(scale), this.squaredSumOverMask(mask, lossTensor))) } private squaredSumOverMask(mask: tf.Tensor<tf.Rank>, lossTensor: tf.Tensor4D): tf.Tensor4D { return tf.tidy(() => tf.sum(tf.square(tf.mul(mask, lossTensor)))) } private validateGroundTruthBoxes(groundTruth: GroundTruth[]) { groundTruth.forEach(({ x, y, width, height, label }) => { if (typeof label !== 'number' || label < 0 || label > (this.config.classes.length - 1)) { throw new Error(`invalid ground truth data, expected label to be a number in [0, ${this.config.classes.length - 1}]`) } if (x < 0 || x > 1 || y < 0 || y > 1 || width < 0 || (x + width) > 1 || height < 0 || (y + height) > 1) { throw new Error(`invalid ground truth data, box is out of image boundaries ${JSON.stringify({ x, y, width, height})}`) } }) } private assignGroundTruthToAnchors(groundTruth: GroundTruth[]) { const groundTruthBoxes = groundTruth .map(({ x, y, width, height, label }) => ({ box: new Rect(x, y, width, height), label })) return groundTruthBoxes.map(({ box, label }) => { const { left, top, bottom, right, x, y, width, height } = box.rescale(this.reshapedImgDims) const ctX = left + (width / 2) const ctY = top + (height / 2) const col = Math.floor((ctX / this.inputSize) * this.numCells) const row = Math.floor((ctY / this.inputSize) * this.numCells) const anchorsByIou = this.anchors.map((anchor, idx) => ({ idx, iou: iou( new BoundingBox(0, 0, anchor.x * CELL_SIZE, anchor.y * CELL_SIZE), new BoundingBox(0, 0, width, height) ) })).sort((a1, a2) => a2.iou - a1.iou) const anchor = anchorsByIou[0].idx return { row, col, anchor, box, label } }) } private createGroundTruthMask() { const mask = tf.zeros([this.numCells, this.numCells, this.numBoxes, this.boxEncodingSize]) const buf = mask.buffer() this.groundTruth.forEach(({ row, col, anchor }) => { for (let i = 0; i < this.boxEncodingSize; i++) { buf.set(1, row, col, anchor, i) } }) return mask } private createCoordAndScoreMasks() { return tf.tidy(() => { const coordBoxOffsetMask = tf.zeros([this.numCells, this.numCells, this.numBoxes, this.boxEncodingSize]) const coordBoxSizeMask = tf.zeros([this.numCells, this.numCells, this.numBoxes, this.boxEncodingSize]) const scoreMask = tf.zeros([this.numCells, this.numCells, this.numBoxes, this.boxEncodingSize]) const coordBoxOffsetBuf = coordBoxOffsetMask.buffer() const coordBoxSizeBuf = coordBoxSizeMask.buffer() const scoreBuf = scoreMask.buffer() for (let row = 0; row < this.numCells; row++) { for (let col = 0; col < this.numCells; col++) { for (let anchor = 0; anchor < this.numBoxes; anchor++) { coordBoxOffsetBuf.set(1, row, col, anchor, 0) coordBoxOffsetBuf.set(1, row, col, anchor, 1) coordBoxSizeBuf.set(1, row, col, anchor, 2) coordBoxSizeBuf.set(1, row, col, anchor, 3) scoreBuf.set(1, row, col, anchor, 4) } } } return { coordBoxOffsetMask, coordBoxSizeMask, scoreMask } }) } private createOneHotClassScoreMask() { const mask = tf.zeros([this.numCells, this.numCells, this.numBoxes, this.boxEncodingSize]) const buf = mask.buffer() const classValuesOffset = 5 this.groundTruth.forEach(({ row, col, anchor, label }) => { buf.set(1, row, col, anchor, classValuesOffset + label) }) return mask } private computeIous() { const isSameAnchor = (p1: GridPosition) => (p2: GridPosition) => p1.row === p2.row && p1.col === p2.col && p1.anchor === p2.anchor const ious = tf.zeros([this.numCells, this.numCells, this.gridCellEncodingSize]) const buf = ious.buffer() this.groundTruth.forEach(({ row, col, anchor, box }) => { const predBox = this.predictedBoxes.find(isSameAnchor({ row, col, anchor })) if (!predBox) { throw new Error(`no output box found for: row ${row}, col ${col}, anchor ${anchor}`) } const boxIou = iou( box.rescale(this.reshapedImgDims), predBox.box.rescale(this.reshapedImgDims) ) const anchorOffset = this.boxEncodingSize * anchor const scoreValueOffset = 4 buf.set(boxIou, row, col, anchorOffset + scoreValueOffset) }) return ious } public computeCoordBoxOffsets() { const offsets = tf.zeros([this.numCells, this.numCells, this.numBoxes, this.boxEncodingSize]) const buf = offsets.buffer() this.groundTruth.forEach(({ row, col, anchor, box }) => { const { left, top, right, bottom } = box.rescale(this.reshapedImgDims) const centerX = (left + right) / 2 const centerY = (top + bottom) / 2 const dCenterX = centerX - (col * CELL_SIZE) const dCenterY = centerY - (row * CELL_SIZE) // inverseSigmoid(0) === -Infinity, inverseSigmoid(1) === Infinity //const dx = inverseSigmoid(Math.min(0.999, Math.max(0.001, dCenterX / CELL_SIZE))) //const dy = inverseSigmoid(Math.min(0.999, Math.max(0.001, dCenterY / CELL_SIZE))) const dx = dCenterX / CELL_SIZE const dy = dCenterY / CELL_SIZE buf.set(dx, row, col, anchor, 0) buf.set(dy, row, col, anchor, 1) }) return offsets } public computeCoordBoxSizes() { const sizes = tf.zeros([this.numCells, this.numCells, this.numBoxes, this.boxEncodingSize]) const buf = sizes.buffer() this.groundTruth.forEach(({ row, col, anchor, box }) => { const { width, height } = box.rescale(this.reshapedImgDims) const dw = Math.log(width / (this.anchors[anchor].x * CELL_SIZE)) const dh = Math.log(height / (this.anchors[anchor].y * CELL_SIZE)) buf.set(dw, row, col, anchor, 2) buf.set(dh, row, col, anchor, 3) }) return sizes } }