You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
495 lines
18 KiB
TypeScript
495 lines
18 KiB
TypeScript
import { Box } from '../common/box';
|
|
import { Cluster } from '../common/cluster';
|
|
import { HSV, hsv2rgb, RGB } from '../common/hsv2rgb';
|
|
import { ImageDebug } from '../common/image-debug';
|
|
import { ImageWrapper } from '../common/image-wrapper';
|
|
import { Moment } from '../common/moment';
|
|
import { Point } from '../common/point';
|
|
import { calculatePatchSize } from '../input/input-stream-utils';
|
|
import { BarcodeLocatorConfig } from './barcode-locator-config';
|
|
import { halfSample, invert, otsuThreshold, transformWithMatrix } from './barcode-locator-utils';
|
|
import { Rasterizer } from './rasterizer';
|
|
import skeletonizer from './skeletonizer';
|
|
import { SearchDirections } from './tracer';
|
|
|
|
interface Patch {
|
|
box: Box;
|
|
index: number;
|
|
moments: Array<Moment>;
|
|
pos: Point;
|
|
rad: number;
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
type Sceletonizer = any;
|
|
|
|
const MomentSimilarityThreshold = 0.9;
|
|
|
|
export class BarcodeLocator {
|
|
private _config: BarcodeLocatorConfig;
|
|
private _inputImageWrapper: ImageWrapper;
|
|
private _currentImageWrapper: ImageWrapper;
|
|
private _skelImageWrapper: ImageWrapper;
|
|
private _subImageWrapper: ImageWrapper;
|
|
private _labelImageWrapper: ImageWrapper<Array<number>>;
|
|
private _binaryImageWrapper: ImageWrapper;
|
|
private _patchGrid: ImageWrapper;
|
|
private _patchLabelGrid: ImageWrapper<Int32Array>;
|
|
private _imageToPatchGrid: Array<Patch>;
|
|
private _patchSize: Point;
|
|
private _binaryContext: CanvasRenderingContext2D;
|
|
private _numPatches: Point;
|
|
private _skeletonizer: Sceletonizer;
|
|
|
|
constructor(inputImageWrapper: ImageWrapper, config: BarcodeLocatorConfig) {
|
|
this._config = config;
|
|
this._inputImageWrapper = inputImageWrapper;
|
|
this._numPatches = { x: 0, y: 0 };
|
|
|
|
this._initBuffers();
|
|
this._initCanvas();
|
|
}
|
|
|
|
locate() {
|
|
if (this._config.halfSample) {
|
|
halfSample(this._inputImageWrapper, this._currentImageWrapper);
|
|
}
|
|
|
|
this._binarizeImage();
|
|
const patchesFound = this._findPatches();
|
|
// return unless 5% or more patches are found
|
|
if (patchesFound.length < this._numPatches.x * this._numPatches.y * 0.05) {
|
|
return null;
|
|
}
|
|
|
|
// rasterize area by comparing angular similarity;
|
|
const maxLabel = this._rasterizeAngularSimilarity(patchesFound);
|
|
if (maxLabel < 1) {
|
|
return null;
|
|
}
|
|
|
|
// search for area with the most patches (biggest connected area)
|
|
const topLabels = this._findBiggestConnectedAreas(maxLabel);
|
|
if (topLabels.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const boxes = this._findBoxes(topLabels, maxLabel);
|
|
return boxes;
|
|
}
|
|
|
|
private _initBuffers(): void {
|
|
if (this._config.halfSample) {
|
|
this._currentImageWrapper = new ImageWrapper({
|
|
x: this._inputImageWrapper.size.x / 2 | 0,
|
|
y: this._inputImageWrapper.size.y / 2 | 0
|
|
});
|
|
} else {
|
|
this._currentImageWrapper = this._inputImageWrapper;
|
|
}
|
|
|
|
this._patchSize = calculatePatchSize(this._config.patchSize, this._currentImageWrapper.size);
|
|
|
|
this._numPatches.x = this._currentImageWrapper.size.x / this._patchSize.x | 0;
|
|
this._numPatches.y = this._currentImageWrapper.size.y / this._patchSize.y | 0;
|
|
|
|
this._binaryImageWrapper = new ImageWrapper(this._currentImageWrapper.size, undefined, Uint8Array, false);
|
|
|
|
this._labelImageWrapper = new ImageWrapper(this._patchSize, undefined, Array, true);
|
|
|
|
const skeletonImageData = new ArrayBuffer(64 * 1024);
|
|
this._subImageWrapper = new ImageWrapper(this._patchSize, new Uint8Array(skeletonImageData, 0, this._patchSize.x * this._patchSize.y));
|
|
this._skelImageWrapper = new ImageWrapper(this._patchSize,
|
|
new Uint8Array(skeletonImageData, this._patchSize.x * this._patchSize.y * 3, this._patchSize.x * this._patchSize.y),
|
|
undefined, true);
|
|
this._skeletonizer = skeletonizer(
|
|
(typeof window !== 'undefined') ? window : (typeof self !== 'undefined') ? self : global,
|
|
{ size: this._patchSize.x },
|
|
skeletonImageData
|
|
);
|
|
|
|
const size = {
|
|
x: (this._currentImageWrapper.size.x / this._subImageWrapper.size.x) | 0,
|
|
y: (this._currentImageWrapper.size.y / this._subImageWrapper.size.y) | 0
|
|
};
|
|
this._patchLabelGrid = new ImageWrapper(size, undefined, Int32Array, true);
|
|
this._patchGrid = new ImageWrapper(size, undefined, undefined, true);
|
|
this._imageToPatchGrid = new Array<Patch>(this._patchLabelGrid.data.length);
|
|
}
|
|
|
|
private _initCanvas() {
|
|
if (this._config.useWorker || typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const canvas = document.createElement('canvas');
|
|
canvas.className = 'binaryBuffer';
|
|
canvas.width = this._binaryImageWrapper.size.x;
|
|
canvas.height = this._binaryImageWrapper.size.y;
|
|
if (process.env.NODE_ENV !== 'production' && this._config.debug && this._config.debug.showCanvas) {
|
|
document.querySelector('#debug').appendChild(canvas);
|
|
}
|
|
this._binaryContext = canvas.getContext('2d');
|
|
}
|
|
|
|
/**
|
|
* Creates a bounding box which encloses all the given patches
|
|
* @returns The minimal bounding box
|
|
*/
|
|
private _boxFromPatches(patches: Array<Patch>): Box {
|
|
const debug = process.env.NODE_ENV !== 'production' && this._config.debug;
|
|
let averageRad = patches.reduce((sum, { pos, rad }) => {
|
|
if (debug && debug.showPatches) {
|
|
// draw all patches which are to be taken into consideration
|
|
this._drawRect(pos, this._subImageWrapper.size, 'red', 1);
|
|
}
|
|
|
|
return sum + rad;
|
|
}, 0) / patches.length;
|
|
|
|
averageRad = (averageRad * 180 / Math.PI + 90) % 180 - 90;
|
|
if (averageRad < 0) {
|
|
averageRad += 180;
|
|
}
|
|
averageRad = (180 - averageRad) * Math.PI / 180;
|
|
|
|
const cos = Math.cos(averageRad);
|
|
const sin = Math.sin(averageRad);
|
|
const matrix = new Float32Array([cos, sin, -sin, cos]);
|
|
const inverseMatrix = invert(matrix);
|
|
|
|
// iterate over patches and rotate by angle
|
|
patches.forEach(({ box }) => {
|
|
for (let j = 0; j < 4; j++) {
|
|
box[j] = transformWithMatrix(box[j], matrix);
|
|
}
|
|
|
|
if (debug && debug.boxFromPatches.showTransformed) {
|
|
this._drawPath(box, '#99ff00', 2);
|
|
}
|
|
});
|
|
|
|
let minX = this._binaryImageWrapper.size.x;
|
|
let minY = this._binaryImageWrapper.size.y;
|
|
let maxX = -minX;
|
|
let maxY = -minY;
|
|
|
|
// find bounding box
|
|
patches.forEach(({ box }) => {
|
|
box.forEach(({ x, y }) => {
|
|
if (x < minX) {
|
|
minX = x;
|
|
}
|
|
if (x > maxX) {
|
|
maxX = x;
|
|
}
|
|
if (y < minY) {
|
|
minY = y;
|
|
}
|
|
if (y > maxY) {
|
|
maxY = y;
|
|
}
|
|
});
|
|
});
|
|
|
|
let box: Box = [{ x: minX, y: minY }, { x: maxX, y: minY }, { x: maxX, y: maxY }, { x: minX, y: maxY }];
|
|
|
|
if (debug && debug.boxFromPatches.showTransformedBox) {
|
|
this._drawPath(box, '#ff0000', 2);
|
|
}
|
|
|
|
// reverse rotation
|
|
box = box.map(vertex => transformWithMatrix(vertex, inverseMatrix)) as Box;
|
|
|
|
if (debug && debug.boxFromPatches.showBB) {
|
|
this._drawPath(box, '#ff0000', 2);
|
|
}
|
|
|
|
if (this._config.halfSample) {
|
|
// scale
|
|
box = box.map(({ x, y }) => ({ x: x * 2, y: y *= 2 })) as Box;
|
|
}
|
|
|
|
return box;
|
|
}
|
|
|
|
/**
|
|
* Creates a binary image of the current image
|
|
*/
|
|
private _binarizeImage(): void {
|
|
otsuThreshold(this._currentImageWrapper, this._binaryImageWrapper);
|
|
this._binaryImageWrapper.zeroBorder();
|
|
|
|
if (process.env.NODE_ENV !== 'production' && this._config.debug && this._config.debug.showCanvas) {
|
|
this._binaryImageWrapper.show(this._binaryContext, 255);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Iterate over the entire image, extract patches
|
|
*/
|
|
private _findPatches(): Array<Patch> {
|
|
const debug = process.env.NODE_ENV !== 'production' && this._config.debug;
|
|
let patchesFound = new Array<Patch>();
|
|
|
|
for (let i = 0; i < this._numPatches.x; i++) {
|
|
for (let j = 0; j < this._numPatches.y; j++) {
|
|
const x = this._subImageWrapper.size.x * i;
|
|
const y = this._subImageWrapper.size.y * j;
|
|
|
|
// seperate parts
|
|
this._skeletonize(x, y);
|
|
|
|
// Rasterize, find individual bars
|
|
this._skelImageWrapper.zeroBorder();
|
|
this._labelImageWrapper.data.fill(0);
|
|
const rasterizer = new Rasterizer(this._skelImageWrapper, this._labelImageWrapper);
|
|
const rasterResult = rasterizer.rasterize(0);
|
|
|
|
if (debug && debug.showLabels) {
|
|
this._labelImageWrapper.overlay(this._binaryContext, 360 / rasterResult.count | 0, x, y);
|
|
}
|
|
|
|
// calculate moments from the skeletonized patch
|
|
const moments = this._labelImageWrapper.moments(rasterResult.count);
|
|
|
|
// extract eligible patches
|
|
const patch = this._describePatch(moments, j * this._numPatches.x + i, x, y);
|
|
if (patch) {
|
|
patchesFound.push(patch);
|
|
|
|
if (debug && debug.showFoundPatches) {
|
|
this._drawRect(patch.pos, this._subImageWrapper.size, '#99ff00', 2);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return patchesFound;
|
|
}
|
|
|
|
/**
|
|
* Finds those connected areas which contain at least 6 patches
|
|
* and returns them ordered DESC by the number of contained patches
|
|
* @param maxLabel
|
|
*/
|
|
private _findBiggestConnectedAreas(maxLabel: number): Array<number> {
|
|
let labelHist = new Array<number>(maxLabel).fill(0);
|
|
|
|
this._patchLabelGrid.data.forEach((data: number) => {
|
|
if (data > 0) {
|
|
labelHist[data - 1]++;
|
|
}
|
|
});
|
|
|
|
// extract top areas with at least 6 patches present
|
|
const topLabels = labelHist.map((value, index) => ({ value, index }))
|
|
.filter(({ value }) => value >= 5).sort((a, b) => b.value - a.value).map(({ index }) => index + 1);
|
|
|
|
return topLabels;
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
private _findBoxes(topLabels: Array<number>, maxLabel: number): Array<Box> {
|
|
const boxes = new Array<Box>();
|
|
const showRemainingPatchLabels = process.env.NODE_ENV !== 'production' &&
|
|
this._config.debug && this._config.debug.showRemainingPatchLabels;
|
|
|
|
topLabels.forEach(label => {
|
|
const patches = new Array<Patch>();
|
|
|
|
this._patchLabelGrid.data.forEach((data: number, index: number) => {
|
|
if (data === label) {
|
|
patches.push(this._imageToPatchGrid[index]);
|
|
}
|
|
});
|
|
|
|
const box = this._boxFromPatches(patches);
|
|
|
|
if (box) {
|
|
boxes.push(box);
|
|
|
|
if (showRemainingPatchLabels) {
|
|
// draw patch-labels if requested
|
|
const hsv: HSV = [(label / (maxLabel + 1)) * 360, 1, 1];
|
|
const rgb: RGB = [0, 0, 0];
|
|
hsv2rgb(hsv, rgb);
|
|
|
|
const color = `rgb(${rgb.join(',')})`;
|
|
|
|
patches.forEach(({ pos }) => this._drawRect(pos, this._subImageWrapper.size, color, 2));
|
|
}
|
|
}
|
|
});
|
|
|
|
return boxes;
|
|
}
|
|
|
|
/**
|
|
* Find similar moments (via cluster)
|
|
* @param moments
|
|
*/
|
|
private _similarMoments(moments: Array<Moment>): Array<Moment> {
|
|
const clusters = Cluster.clusterize(moments, MomentSimilarityThreshold);
|
|
const topCluster = clusters.reduce((top, item) => {
|
|
const count = item.moments.length;
|
|
return count > top.count ? { item, count } : top;
|
|
}, { item: { moments: [] }, count: 0 });
|
|
const result = topCluster.item.moments;
|
|
|
|
return result;
|
|
}
|
|
|
|
private _skeletonize(x: number, y: number): void {
|
|
this._binaryImageWrapper.subImageAsCopy(this._subImageWrapper, x, y);
|
|
this._skeletonizer.skeletonize();
|
|
|
|
// Show skeleton if requested
|
|
if (process.env.NODE_ENV !== 'production' && this._config.debug && this._config.debug.showSkeleton) {
|
|
this._skelImageWrapper.overlay(this._binaryContext, 360, x, y);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extracts and describes those patches which seem to contain a barcode pattern
|
|
* @param moments
|
|
* @param index
|
|
* @param x
|
|
* @param y
|
|
* @returns list of patches
|
|
*/
|
|
private _describePatch(moments: Array<Moment>, index: number, x: number, y: number): Patch {
|
|
if (moments.length > 1) {
|
|
const minComponentWeight = Math.ceil(this._patchSize.x / 3);
|
|
// only collect moments which area covers at least minComponentWeight pixels
|
|
const eligibleMoments = moments.filter(moment => moment.m00 > minComponentWeight);
|
|
|
|
// if at least 2 moments are found which have at least minComponentWeights covered
|
|
if (eligibleMoments.length > 1) {
|
|
const matchingMoments = this._similarMoments(eligibleMoments);
|
|
const length = matchingMoments.length | 0;
|
|
|
|
// Only two of the moments are allowed not to fit into the equation
|
|
if (length > 1 && (length << 2) >= eligibleMoments.length * 3 && (length << 2) > moments.length) {
|
|
// determine the similarity of the moments
|
|
const rad = matchingMoments.reduce((sum: number, moment: Moment) => sum + moment.rad, 0) / length;
|
|
|
|
return {
|
|
index,
|
|
pos: { x, y },
|
|
box: [
|
|
{ x, y },
|
|
{ x: x + this._subImageWrapper.size.x, y },
|
|
{ x: x + this._subImageWrapper.size.x, y: y + this._subImageWrapper.size.y },
|
|
{ x, y: y + this._subImageWrapper.size.y }
|
|
],
|
|
moments: matchingMoments,
|
|
rad,
|
|
x: Math.cos(rad),
|
|
y: Math.sin(rad)
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private _notYetProcessed(): number {
|
|
for (let i = 0; i < this._patchLabelGrid.data.length; i++) {
|
|
if (this._patchLabelGrid.data[i] === 0 && this._patchGrid.data[i] === 1) {
|
|
return i;
|
|
}
|
|
}
|
|
return this._patchLabelGrid.data.length;
|
|
}
|
|
|
|
private _trace(currentIndex: number, label: number): void {
|
|
const threshold = 0.95;
|
|
const current: Point = {
|
|
x: currentIndex % this._patchLabelGrid.size.x,
|
|
y: (currentIndex / this._patchLabelGrid.size.x) | 0
|
|
};
|
|
|
|
if (currentIndex < this._patchLabelGrid.data.length) {
|
|
const currentPatch = this._imageToPatchGrid[currentIndex];
|
|
// assign label
|
|
this._patchLabelGrid.data[currentIndex] = label;
|
|
|
|
SearchDirections.forEach(direction => {
|
|
const y = current.y + direction[0];
|
|
const x = current.x + direction[1];
|
|
const index = y * this._patchLabelGrid.size.x + x;
|
|
|
|
// continue if patch empty
|
|
if (this._patchGrid.data[index] === 0) {
|
|
this._patchLabelGrid.data[index] = Number.MAX_VALUE;
|
|
} else if (this._patchLabelGrid.data[index] === 0) {
|
|
const patch = this._imageToPatchGrid[index];
|
|
const similarity = Math.abs(patch.x * currentPatch.x + patch.y * currentPatch.y);
|
|
if (similarity > threshold) {
|
|
this._trace(index, label);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finds patches which are connected and share the same orientation
|
|
* @param patchesFound
|
|
*/
|
|
private _rasterizeAngularSimilarity(patchesFound: Array<Patch>): number {
|
|
let label = 0;
|
|
const hsv: HSV = [0, 1, 1];
|
|
const rgb: RGB = [0, 0, 0];
|
|
|
|
// prepare for finding the right patches
|
|
this._patchGrid.data.fill(0);
|
|
this._patchLabelGrid.data.fill(0);
|
|
this._imageToPatchGrid.fill(null);
|
|
|
|
patchesFound.forEach(patch => {
|
|
this._imageToPatchGrid[patch.index] = patch;
|
|
this._patchGrid.data[patch.index] = 1;
|
|
});
|
|
|
|
// rasterize the patches found to determine area
|
|
this._patchGrid.zeroBorder();
|
|
|
|
let currentIndex = 0;
|
|
while ((currentIndex = this._notYetProcessed()) < this._patchLabelGrid.data.length) {
|
|
label++;
|
|
this._trace(currentIndex, label);
|
|
}
|
|
|
|
// draw patch-labels if requested
|
|
if (process.env.NODE_ENV !== 'production' && this._config.debug && this._config.debug.showPatchLabels) {
|
|
for (let j = 0; j < this._patchLabelGrid.data.length; j++) {
|
|
if (this._patchLabelGrid.data[j] > 0 && this._patchLabelGrid.data[j] <= label) {
|
|
const patch = this._imageToPatchGrid[j];
|
|
hsv[0] = (this._patchLabelGrid.data[j] / (label + 1)) * 360;
|
|
hsv2rgb(hsv, rgb);
|
|
this._drawRect(patch.pos, this._subImageWrapper.size, `rgb(${rgb.join(',')})`, 2);
|
|
}
|
|
}
|
|
}
|
|
|
|
return label;
|
|
}
|
|
|
|
private _drawRect({ x, y }: Point, size: Point, color: string, lineWidth: number): void {
|
|
this._binaryContext.strokeStyle = color;
|
|
this._binaryContext.fillStyle = color;
|
|
this._binaryContext.lineWidth = lineWidth || 1;
|
|
this._binaryContext.strokeRect(x, y, size.x, size.y);
|
|
}
|
|
|
|
private _drawPath(path: Array<Point>, color: string, lineWidth: number): void {
|
|
ImageDebug.drawPath(path, this._binaryContext, color, lineWidth);
|
|
}
|
|
}
|