/* * Copyright (C) 2018 Intel Corporation * SPDX-License-Identifier: MIT */ /* global require:false */ (() => { const ObjectState = require('./object-state'); const { checkObjectType } = require('./common'); const { ObjectShape, ObjectType, AttributeType, } = require('./enums'); const { DataError, ArgumentError, ScriptingError, } = require('./exceptions'); const { Label } = require('./labels'); // Called with the Annotation context function objectStateFactory(frame, data) { const objectState = new ObjectState(data); objectState.hidden = { save: this.save.bind(this, frame, objectState), delete: this.delete.bind(this), up: this.up.bind(this, frame, objectState), down: this.down.bind(this, frame, objectState), }; return objectState; } function checkNumberOfPoints(shapeType, points) { if (shapeType === ObjectShape.RECTANGLE) { if (points.length / 2 !== 2) { throw new DataError( `Rectangle must have 2 points, but got ${points.length / 2}`, ); } } else if (shapeType === ObjectShape.POLYGON) { if (points.length / 2 < 3) { throw new DataError( `Polygon must have at least 3 points, but got ${points.length / 2}`, ); } } else if (shapeType === ObjectShape.POLYLINE) { if (points.length / 2 < 2) { throw new DataError( `Polyline must have at least 2 points, but got ${points.length / 2}`, ); } } else if (shapeType === ObjectShape.POINTS) { if (points.length / 2 < 1) { throw new DataError( `Points must have at least 1 points, but got ${points.length / 2}`, ); } } else { throw new ArgumentError( `Unknown value of shapeType has been recieved ${shapeType}`, ); } } function checkShapeArea(shapeType, points) { const MIN_SHAPE_LENGTH = 3; const MIN_SHAPE_AREA = 9; if (shapeType === ObjectShape.POINTS) { return true; } let xmin = Number.MAX_SAFE_INTEGER; let xmax = Number.MIN_SAFE_INTEGER; let ymin = Number.MAX_SAFE_INTEGER; let ymax = Number.MIN_SAFE_INTEGER; for (let i = 0; i < points.length - 1; i += 2) { xmin = Math.min(xmin, points[i]); xmax = Math.max(xmax, points[i]); ymin = Math.min(ymin, points[i + 1]); ymax = Math.max(ymax, points[i + 1]); } if (shapeType === ObjectShape.POLYLINE) { const length = Math.max( xmax - xmin, ymax - ymin, ); return length >= MIN_SHAPE_LENGTH; } const area = (xmax - xmin) * (ymax - ymin); return area >= MIN_SHAPE_AREA; } function validateAttributeValue(value, attr) { const { values } = attr; const type = attr.inputType; if (typeof (value) !== 'string') { throw new ArgumentError( `Attribute value is expected to be string, but got ${typeof (value)}`, ); } if (type === AttributeType.NUMBER) { return +value >= +values[0] && +value <= +values[1] && !((+value - +values[0]) % +values[2]); } if (type === AttributeType.CHECKBOX) { return ['true', 'false'].includes(value.toLowerCase()); } return values.includes(value); } class Annotation { constructor(data, clientID, injection) { this.taskLabels = injection.labels; this.clientID = clientID; this.serverID = data.id; this.group = data.group; this.label = this.taskLabels[data.label_id]; this.frame = data.frame; this.removed = false; this.lock = false; this.attributes = data.attributes.reduce((attributeAccumulator, attr) => { attributeAccumulator[attr.spec_id] = attr.value; return attributeAccumulator; }, {}); this.appendDefaultAttributes(this.label); injection.groups.max = Math.max(injection.groups.max, this.group); } appendDefaultAttributes(label) { const labelAttributes = label.attributes; for (const attribute of labelAttributes) { if (!(attribute.id in this.attributes)) { this.attributes[attribute.id] = attribute.defaultValue; } } } delete(force) { if (!this.lock || force) { this.removed = true; } return true; } } class Drawn extends Annotation { constructor(data, clientID, color, injection) { super(data, clientID, injection); this.frameMeta = injection.frameMeta; this.collectionZ = injection.collectionZ; this.color = color; this.shapeType = null; } _getZ(frame) { this.collectionZ[frame] = this.collectionZ[frame] || { max: 0, min: 0, }; return this.collectionZ[frame]; } save() { throw new ScriptingError( 'Is not implemented', ); } get() { throw new ScriptingError( 'Is not implemented', ); } toJSON() { throw new ScriptingError( 'Is not implemented', ); } // Increase ZOrder within frame up(frame, objectState) { const z = this._getZ(frame); z.max++; objectState.zOrder = z.max; } // Decrease ZOrder within frame down(frame, objectState) { const z = this._getZ(frame); z.min--; objectState.zOrder = z.min; } } class Shape extends Drawn { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); this.points = data.points; this.occluded = data.occluded; this.zOrder = data.z_order; const z = this._getZ(this.frame); z.max = Math.max(z.max, this.zOrder || 0); z.min = Math.min(z.min, this.zOrder || 0); } // Method is used to export data to the server toJSON() { return { type: this.shapeType, clientID: this.clientID, occluded: this.occluded, z_order: this.zOrder, points: [...this.points], attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => { attributeAccumulator.push({ spec_id: attrId, value: this.attributes[attrId], }); return attributeAccumulator; }, []), id: this.serverID, frame: this.frame, label_id: this.label.id, group: this.group, }; } // Method is used to construct ObjectState objects get(frame) { if (frame !== this.frame) { throw new ScriptingError( 'Got frame is not equal to the frame of the shape', ); } return { objectType: ObjectType.SHAPE, shapeType: this.shapeType, clientID: this.clientID, serverID: this.serverID, occluded: this.occluded, lock: this.lock, zOrder: this.zOrder, points: [...this.points], attributes: Object.assign({}, this.attributes), label: this.label, group: this.group, color: this.color, }; } save(frame, data) { if (frame !== this.frame) { throw new ScriptingError( 'Got frame is not equal to the frame of the shape', ); } if (this.lock && data.lock) { return objectStateFactory.call(this, frame, this.get(frame)); } // All changes are done in this temporary object const copy = this.get(frame); const updated = data.updateFlags; if (updated.label) { checkObjectType('label', data.label, null, Label); copy.label = data.label; copy.attributes = {}; this.appendDefaultAttributes.call(copy, copy.label); } if (updated.attributes) { const labelAttributes = copy.label.attributes .reduce((accumulator, value) => { accumulator[value.id] = value; return accumulator; }, {}); for (const attrID of Object.keys(data.attributes)) { const value = data.attributes[attrID]; if (attrID in labelAttributes && validateAttributeValue(value, labelAttributes[attrID])) { copy.attributes[attrID] = value; } else { throw new ArgumentError( `Trying to save unknown attribute with id ${attrID} and value ${value}`, ); } } } if (updated.points) { checkObjectType('points', data.points, null, Array); checkNumberOfPoints(this.shapeType, data.points); // cut points const { width, height } = this.frameMeta[frame]; const cutPoints = []; for (let i = 0; i < data.points.length - 1; i += 2) { const x = data.points[i]; const y = data.points[i + 1]; checkObjectType('coordinate', x, 'number', null); checkObjectType('coordinate', y, 'number', null); cutPoints.push( Math.clamp(x, 0, width), Math.clamp(y, 0, height), ); } if (checkShapeArea(this.shapeType, cutPoints)) { copy.points = cutPoints; } } if (updated.occluded) { checkObjectType('occluded', data.occluded, 'boolean', null); copy.occluded = data.occluded; } if (updated.group) { checkObjectType('group', data.group, 'integer', null); copy.group = data.group; } if (updated.zOrder) { checkObjectType('zOrder', data.zOrder, 'integer', null); copy.zOrder = data.zOrder; } if (updated.lock) { checkObjectType('lock', data.lock, 'boolean', null); copy.lock = data.lock; } if (updated.color) { checkObjectType('color', data.color, 'string', null); if (/^#[0-9A-F]{6}$/i.test(data.color)) { throw new ArgumentError( `Got invalid color value: "${data.color}"`, ); } copy.color = data.color; } // Reset flags and commit all changes updated.reset(); for (const prop of Object.keys(copy)) { if (prop in this) { this[prop] = copy[prop]; } } return objectStateFactory.call(this, frame, this.get(frame)); } } class Track extends Drawn { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); this.shapes = data.shapes.reduce((shapeAccumulator, value) => { shapeAccumulator[value.frame] = { serverID: value.id, occluded: value.occluded, zOrder: value.z_order, points: value.points, outside: value.outside, attributes: value.attributes.reduce((attributeAccumulator, attr) => { attributeAccumulator[attr.spec_id] = attr.value; return attributeAccumulator; }, {}), }; const z = this._getZ(value.frame); z.max = Math.max(z.max, value.z_order); z.min = Math.min(z.min, value.z_order); return shapeAccumulator; }, {}); this.cache = {}; } // Method is used to export data to the server toJSON() { const labelAttributes = this.label.attributes.reduce((accumulator, attribute) => { accumulator[attribute.id] = attribute; return accumulator; }, {}); return { clientID: this.clientID, id: this.serverID, frame: this.frame, label_id: this.label.id, group: this.group, attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => { if (!labelAttributes[attrId].mutable) { attributeAccumulator.push({ spec_id: attrId, value: this.attributes[attrId], }); } return attributeAccumulator; }, []), shapes: Object.keys(this.shapes).reduce((shapesAccumulator, frame) => { shapesAccumulator.push({ type: this.shapeType, occluded: this.shapes[frame].occluded, z_order: this.shapes[frame].zOrder, points: [...this.shapes[frame].points], outside: this.shapes[frame].outside, attributes: Object.keys(this.shapes[frame].attributes) .reduce((attributeAccumulator, attrId) => { if (labelAttributes[attrId].mutable) { attributeAccumulator.push({ spec_id: attrId, value: this.shapes[frame].attributes[attrId], }); } return attributeAccumulator; }, []), id: this.shapes[frame].serverID, frame: +frame, }); return shapesAccumulator; }, []), }; } // Method is used to construct ObjectState objects get(frame) { if (!(frame in this.cache)) { const interpolation = Object.assign( {}, this.getPosition(frame), { attributes: this.getAttributes(frame), group: this.group, objectType: ObjectType.TRACK, shapeType: this.shapeType, clientID: this.clientID, serverID: this.serverID, lock: this.lock, color: this.color, }, ); this.cache[frame] = interpolation; } const result = JSON.parse(JSON.stringify(this.cache[frame])); result.label = this.label; return result; } neighborsFrames(targetFrame) { const frames = Object.keys(this.shapes).map(frame => +frame); let lDiff = Number.MAX_SAFE_INTEGER; let rDiff = Number.MAX_SAFE_INTEGER; for (const frame of frames) { const diff = Math.abs(targetFrame - frame); if (frame <= targetFrame && diff < lDiff) { lDiff = diff; } else if (diff < rDiff) { rDiff = diff; } } const leftFrame = lDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame - lDiff; const rightFrame = rDiff === Number.MAX_SAFE_INTEGER ? null : targetFrame + rDiff; return { leftFrame, rightFrame, }; } getAttributes(targetFrame) { const result = {}; // First of all copy all unmutable attributes for (const attrID in this.attributes) { if (Object.prototype.hasOwnProperty.call(this.attributes, attrID)) { result[attrID] = this.attributes[attrID]; } } // Secondly get latest mutable attributes up to target frame const frames = Object.keys(this.shapes).sort((a, b) => +a - +b); for (const frame of frames) { if (frame <= targetFrame) { const { attributes } = this.shapes[frame]; for (const attrID in attributes) { if (Object.prototype.hasOwnProperty.call(attributes, attrID)) { result[attrID] = attributes[attrID]; } } } } return result; } save(frame, data) { if (this.lock && data.lock) { return objectStateFactory.call(this, frame, this.get(frame)); } // All changes are done in this temporary object const copy = Object.assign(this.get(frame)); copy.attributes = Object.assign(copy.attributes); copy.points = [...copy.points]; const updated = data.updateFlags; let positionUpdated = false; if (updated.label) { checkObjectType('label', data.label, null, Label); copy.label = data.label; copy.attributes = {}; // Shape attributes will be removed later after all checks this.appendDefaultAttributes.call(copy, copy.label); } const labelAttributes = copy.label.attributes .reduce((accumulator, value) => { accumulator[value.id] = value; return accumulator; }, {}); if (updated.attributes) { for (const attrID of Object.keys(data.attributes)) { const value = data.attributes[attrID]; if (attrID in labelAttributes && validateAttributeValue(value, labelAttributes[attrID])) { copy.attributes[attrID] = value; } else { throw new ArgumentError( `Trying to save unknown attribute with id ${attrID} and value ${value}`, ); } } } if (updated.points) { checkObjectType('points', data.points, null, Array); checkNumberOfPoints(this.shapeType, data.points); // cut points const { width, height } = this.frameMeta[frame]; const cutPoints = []; for (let i = 0; i < data.points.length - 1; i += 2) { const x = data.points[i]; const y = data.points[i + 1]; checkObjectType('coordinate', x, 'number', null); checkObjectType('coordinate', y, 'number', null); cutPoints.push( Math.clamp(x, 0, width), Math.clamp(y, 0, height), ); } if (checkShapeArea(this.shapeType, cutPoints)) { copy.points = cutPoints; positionUpdated = true; } } if (updated.occluded) { checkObjectType('occluded', data.occluded, 'boolean', null); copy.occluded = data.occluded; positionUpdated = true; } if (updated.outside) { checkObjectType('outside', data.outside, 'boolean', null); copy.outside = data.outside; positionUpdated = true; } if (updated.group) { checkObjectType('group', data.group, 'integer', null); copy.group = data.group; } if (updated.zOrder) { checkObjectType('zOrder', data.zOrder, 'integer', null); copy.zOrder = data.zOrder; positionUpdated = true; } if (updated.lock) { checkObjectType('lock', data.lock, 'boolean', null); copy.lock = data.lock; } if (updated.color) { checkObjectType('color', data.color, 'string', null); if (/^#[0-9A-F]{6}$/i.test(data.color)) { throw new ArgumentError( `Got invalid color value: "${data.color}"`, ); } copy.color = data.color; } if (updated.keyframe) { // Just check here checkObjectType('keyframe', data.keyframe, 'boolean', null); } // Commit all changes for (const prop of Object.keys(copy)) { if (prop in this) { this[prop] = copy[prop]; } this.cache[frame][prop] = copy[prop]; } if (updated.attributes) { // Mutable attributes will be updated below for (const attrID of Object.keys(copy.attributes)) { if (!labelAttributes[attrID].mutable) { this.shapes[frame].attributes[attrID] = data.attributes[attrID]; this.shapes[frame].attributes[attrID] = data.attributes[attrID]; } } } if (updated.label) { for (const shape of Object.values(this.shapes)) { shape.attributes = {}; } } // Remove keyframe if (updated.keyframe && !data.keyframe) { // Remove all cache after this keyframe because it have just become outdated for (const cacheFrame in this.cache) { if (+cacheFrame > frame) { delete this.cache[cacheFrame]; } } this.cache[frame].keyframe = false; delete this.shapes[frame]; updated.reset(); return objectStateFactory.call(this, frame, this.get(frame)); } // Add/update keyframe if (positionUpdated || (updated.keyframe && data.keyframe)) { // Remove all cache after this keyframe because it have just become outdated for (const cacheFrame in this.cache) { if (+cacheFrame > frame) { delete this.cache[cacheFrame]; } } this.cache[frame].keyframe = true; data.keyframe = true; this.shapes[frame] = { frame, zOrder: copy.zOrder, points: copy.points, outside: copy.outside, occluded: copy.occluded, attributes: {}, }; if (updated.attributes) { // Unmutable attributes were updated above for (const attrID of Object.keys(copy.attributes)) { if (labelAttributes[attrID].mutable) { this.shapes[frame].attributes[attrID] = data.attributes[attrID]; this.shapes[frame].attributes[attrID] = data.attributes[attrID]; } } } } updated.reset(); return objectStateFactory.call(this, frame, this.get(frame)); } getPosition(targetFrame) { const { leftFrame, rightFrame, } = this.neighborsFrames(targetFrame); const rightPosition = Number.isInteger(rightFrame) ? this.shapes[rightFrame] : null; const leftPosition = Number.isInteger(leftFrame) ? this.shapes[leftFrame] : null; if (leftPosition && leftFrame === targetFrame) { return { points: [...leftPosition.points], occluded: leftPosition.occluded, outside: leftPosition.outside, zOrder: leftPosition.zOrder, keyframe: true, }; } if (rightPosition && leftPosition) { return Object.assign({}, this.interpolatePosition( leftPosition, rightPosition, (targetFrame - leftFrame) / (rightFrame - leftFrame), ), { keyframe: false, }); } if (rightPosition) { return { points: [...rightPosition.points], occluded: rightPosition.occluded, outside: true, zOrder: 0, keyframe: false, }; } if (leftPosition) { return { points: [...leftPosition.points], occluded: leftPosition.occluded, outside: leftPosition.outside, zOrder: 0, keyframe: false, }; } throw new ScriptingError( `No one neightbour frame found for the track with client ID: "${this.id}"`, ); } delete(force) { if (!this.lock || force) { this.removed = true; this.resetCache(); } return true; } resetCache() { this.cache = {}; } } class Tag extends Annotation { constructor(data, clientID, injection) { super(data, clientID, injection); } // Method is used to export data to the server toJSON() { return { clientID: this.clientID, id: this.serverID, frame: this.frame, label_id: this.label.id, group: this.group, attributes: Object.keys(this.attributes).reduce((attributeAccumulator, attrId) => { attributeAccumulator.push({ spec_id: attrId, value: this.attributes[attrId], }); return attributeAccumulator; }, []), }; } // Method is used to construct ObjectState objects get(frame) { if (frame !== this.frame) { throw new ScriptingError( 'Got frame is not equal to the frame of the shape', ); } return { objectType: ObjectType.TAG, clientID: this.clientID, serverID: this.serverID, lock: this.lock, attributes: Object.assign({}, this.attributes), label: this.label, group: this.group, }; } save(frame, data) { if (frame !== this.frame) { throw new ScriptingError( 'Got frame is not equal to the frame of the shape', ); } if (this.lock && data.lock) { return objectStateFactory.call(this, frame, this.get(frame)); } // All changes are done in this temporary object const copy = this.get(frame); const updated = data.updateFlags; if (updated.label) { checkObjectType('label', data.label, null, Label); copy.label = data.label; copy.attributes = {}; this.appendDefaultAttributes.call(copy, copy.label); } if (updated.attributes) { const labelAttributes = copy.label .attributes.map(attr => `${attr.id}`); for (const attrID of Object.keys(data.attributes)) { if (labelAttributes.includes(attrID)) { copy.attributes[attrID] = data.attributes[attrID]; } } } if (updated.group) { checkObjectType('group', data.group, 'integer', null); copy.group = data.group; } if (updated.lock) { checkObjectType('lock', data.lock, 'boolean', null); copy.lock = data.lock; } // Reset flags and commit all changes updated.reset(); for (const prop of Object.keys(copy)) { if (prop in this) { this[prop] = copy[prop]; } } return objectStateFactory.call(this, frame, this.get(frame)); } } class RectangleShape extends Shape { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); this.shapeType = ObjectShape.RECTANGLE; checkNumberOfPoints(this.shapeType, this.points); } static distance(points, x, y) { const [xtl, ytl, xbr, ybr] = points; if (!(x >= xtl && x <= xbr && y >= ytl && y <= ybr)) { // Cursor is outside of a box return null; } // The shortest distance from point to an edge return Math.min.apply(null, [x - xtl, y - ytl, xbr - x, ybr - y]); } } class PolyShape extends Shape { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); } } class PolygonShape extends PolyShape { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); this.shapeType = ObjectShape.POLYGON; checkNumberOfPoints(this.shapeType, this.points); } static distance(points, x, y) { function position(x1, y1, x2, y2) { return ((x2 - x1) * (y - y1) - (x - x1) * (y2 - y1)); } let wn = 0; const distances = []; for (let i = 0, j = points.length - 2; i < points.length - 1; j = i, i += 2) { // Current point const x1 = points[j]; const y1 = points[j + 1]; // Next point const x2 = points[i]; const y2 = points[i + 1]; // Check if a point is inside a polygon // with a winding numbers algorithm // https://en.wikipedia.org/wiki/Point_in_polygon#Winding_number_algorithm if (y1 <= y) { if (y2 > y) { if (position(x1, y1, x2, y2) > 0) { wn++; } } } else if (y2 <= y) { if (position(x1, y1, x2, y2) < 0) { wn--; } } // Find the shortest distance from point to an edge // Get an equation of a line in general const aCoef = (y1 - y2); const bCoef = (x2 - x1); // Vector (aCoef, bCoef) is a perpendicular to line // Now find the point where two lines // (edge and its perpendicular through the point (x,y)) are cross const xCross = x - aCoef; const yCross = y - bCoef; if (((xCross - x1) * (x2 - xCross)) >= 0 && ((yCross - y1) * (y2 - yCross)) >= 0) { // Cross point is on segment between p1(x1,y1) and p2(x2,y2) distances.push(Math.sqrt( Math.pow(x - xCross, 2) + Math.pow(y - yCross, 2), )); } else { distances.push( Math.min( Math.sqrt(Math.pow(x1 - x, 2) + Math.pow(y1 - y, 2)), Math.sqrt(Math.pow(x2 - x, 2) + Math.pow(y2 - y, 2)), ), ); } } if (wn !== 0) { return Math.min.apply(null, distances); } return null; } } class PolylineShape extends PolyShape { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); this.shapeType = ObjectShape.POLYLINE; checkNumberOfPoints(this.shapeType, this.points); } static distance(points, x, y) { const distances = []; for (let i = 0; i < points.length - 2; i += 2) { // Current point const x1 = points[i]; const y1 = points[i + 1]; // Next point const x2 = points[i + 2]; const y2 = points[i + 3]; // Find the shortest distance from point to an edge if (((x - x1) * (x2 - x)) >= 0 && ((y - y1) * (y2 - y)) >= 0) { // Find the length of a perpendicular // https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line distances.push( Math.abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) / Math .sqrt(Math.pow(y2 - y1, 2) + Math.pow(x2 - x1, 2)), ); } else { // The link below works for lines (which have infinit length) // There is a case when perpendicular doesn't cross the edge // In this case we don't use the computed distance // Instead we use just distance to the nearest point distances.push( Math.min( Math.sqrt(Math.pow(x1 - x, 2) + Math.pow(y1 - y, 2)), Math.sqrt(Math.pow(x2 - x, 2) + Math.pow(y2 - y, 2)), ), ); } } return Math.min.apply(null, distances); } } class PointsShape extends PolyShape { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); this.shapeType = ObjectShape.POINTS; checkNumberOfPoints(this.shapeType, this.points); } static distance(points, x, y) { const distances = []; for (let i = 0; i < points.length; i += 2) { const x1 = points[i]; const y1 = points[i + 1]; distances.push( Math.sqrt(Math.pow(x1 - x, 2) + Math.pow(y1 - y, 2)), ); } return Math.min.apply(null, distances); } } class RectangleTrack extends Track { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); this.shapeType = ObjectShape.RECTANGLE; for (const shape of Object.values(this.shapes)) { checkNumberOfPoints(this.shapeType, shape.points); } } interpolatePosition(leftPosition, rightPosition, offset) { const positionOffset = [ rightPosition.points[0] - leftPosition.points[0], rightPosition.points[1] - leftPosition.points[1], rightPosition.points[2] - leftPosition.points[2], rightPosition.points[3] - leftPosition.points[3], ]; return { // xtl, ytl, xbr, ybr points: [ leftPosition.points[0] + positionOffset[0] * offset, leftPosition.points[1] + positionOffset[1] * offset, leftPosition.points[2] + positionOffset[2] * offset, leftPosition.points[3] + positionOffset[3] * offset, ], occluded: leftPosition.occluded, outside: leftPosition.outside, zOrder: leftPosition.zOrder, }; } } class PolyTrack extends Track { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); } interpolatePosition(leftPosition, rightPosition, offset) { function findBox(points) { let xmin = Number.MAX_SAFE_INTEGER; let ymin = Number.MAX_SAFE_INTEGER; let xmax = Number.MIN_SAFE_INTEGER; let ymax = Number.MIN_SAFE_INTEGER; for (let i = 0; i < points.length; i += 2) { if (points[i] < xmin) xmin = points[i]; if (points[i + 1] < ymin) ymin = points[i + 1]; if (points[i] > xmax) xmax = points[i]; if (points[i + 1] > ymax) ymax = points[i + 1]; } return { xmin, ymin, xmax, ymax, }; } function normalize(points, box) { const normalized = []; const width = box.xmax - box.xmin; const height = box.ymax - box.ymin; for (let i = 0; i < points.length; i += 2) { normalized.push( (points[i] - box.xmin) / width, (points[i + 1] - box.ymin) / height, ); } return normalized; } function denormalize(points, box) { const denormalized = []; const width = box.xmax - box.xmin; const height = box.ymax - box.ymin; for (let i = 0; i < points.length; i += 2) { denormalized.push( points[i] * width + box.xmin, points[i + 1] * height + box.ymin, ); } return denormalized; } function toPoints(array) { const points = []; for (let i = 0; i < array.length; i += 2) { points.push({ x: array[i], y: array[i + 1], }); } return points; } function toArray(points) { const array = []; for (const point of points) { array.push(point.x, point.y); } return array; } function computeDistances(source, target) { const distances = {}; for (let i = 0; i < source.length; i++) { distances[i] = distances[i] || {}; for (let j = 0; j < target.length; j++) { const dx = source[i].x - target[j].x; const dy = source[i].y - target[j].y; distances[i][j] = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)); } } return distances; } function truncateByThreshold(mapping, threshold) { for (const key of Object.keys(mapping)) { if (mapping[key].distance > threshold) { delete mapping[key]; } } } // https://en.wikipedia.org/wiki/Stable_marriage_problem // TODO: One of important part of the algorithm is to correctly match // "corner" points. Thus it is possible for each of such point calculate // a descriptor (d) and use (x, y, d) to calculate the distance. One more // idea is to be sure that order or matched points is preserved. For example, // if p1 matches q1 and p2 matches q2 and between p1 and p2 we don't have any // points thus we should not have points between q1 and q2 as well. function stableMarriageProblem(men, women, distances) { const menPreferences = {}; for (const man of men) { menPreferences[man] = women.concat() .sort((w1, w2) => distances[man][w1] - distances[man][w2]); } // Start alghoritm with max N^2 complexity const womenMaybe = {}; // id woman:id man,distance const menBusy = {}; // id man:boolean let prefIndex = 0; // While there is at least one free man while (Object.values(menBusy).length !== men.length) { // Every man makes offer to the best woman for (const man of men) { // The man have already found a woman if (menBusy[man]) { continue; } const woman = menPreferences[man][prefIndex]; const distance = distances[man][woman]; // A women chooses the best offer and says "maybe" if (woman in womenMaybe && womenMaybe[woman].distance > distance) { // A woman got better offer const prevChoice = womenMaybe[woman].value; delete womenMaybe[woman]; delete menBusy[prevChoice]; } if (!(woman in womenMaybe)) { womenMaybe[woman] = { value: man, distance, }; menBusy[man] = true; } } prefIndex++; } const result = {}; for (const woman of Object.keys(womenMaybe)) { result[womenMaybe[woman].value] = { value: woman, distance: womenMaybe[woman].distance, }; } return result; } function getMapping(source, target) { function sumEdges(points) { let result = 0; for (let i = 1; i < points.length; i += 2) { const distance = Math.sqrt(Math.pow(points[i].x - points[i - 1].x, 2) + Math.pow(points[i].y - points[i - 1].y, 2)); result += distance; } // Corner case when work with one point // Mapping in this case can't be wrong if (!result) { return Number.MAX_SAFE_INTEGER; } return result; } function computeDeviation(points, average) { let result = 0; for (let i = 1; i < points.length; i += 2) { const distance = Math.sqrt(Math.pow(points[i].x - points[i - 1].x, 2) + Math.pow(points[i].y - points[i - 1].y, 2)); result += Math.pow(distance - average, 2); } return result; } const processedSource = []; const processedTarget = []; const distances = computeDistances(source, target); const mapping = stableMarriageProblem(Array.from(source.keys()), Array.from(target.keys()), distances); const average = (sumEdges(target) + sumEdges(source)) / (target.length + source.length); const meanSquareDeviation = Math.sqrt((computeDeviation(source, average) + computeDeviation(target, average)) / (source.length + target.length)); const threshold = average + 3 * meanSquareDeviation; // 3 sigma rule truncateByThreshold(mapping, threshold); for (const key of Object.keys(mapping)) { mapping[key] = mapping[key].value; } // const receivingOrder = Object.keys(mapping).map(x => +x).sort((a,b) => a - b); const receivingOrder = this.appendMapping(mapping, source, target); for (const pointIdx of receivingOrder) { processedSource.push(source[pointIdx]); processedTarget.push(target[mapping[pointIdx]]); } return [processedSource, processedTarget]; } let leftBox = findBox(leftPosition.points); let rightBox = findBox(rightPosition.points); // Sometimes (if shape has one point or shape is line), // We can get box with zero area // Next computation will be with NaN in this case // We have to prevent it const delta = 1; if (leftBox.xmax - leftBox.xmin < delta || rightBox.ymax - rightBox.ymin < delta) { leftBox = { xmin: 0, xmax: 1024, // TODO: Get actual image size ymin: 0, ymax: 768, }; rightBox = leftBox; } const leftPoints = toPoints(normalize(leftPosition.points, leftBox)); const rightPoints = toPoints(normalize(rightPosition.points, rightBox)); let newLeftPoints = []; let newRightPoints = []; if (leftPoints.length > rightPoints.length) { const [ processedRight, processedLeft, ] = getMapping.call(this, rightPoints, leftPoints); newLeftPoints = processedLeft; newRightPoints = processedRight; } else { const [ processedLeft, processedRight, ] = getMapping.call(this, leftPoints, rightPoints); newLeftPoints = processedLeft; newRightPoints = processedRight; } const absoluteLeftPoints = denormalize(toArray(newLeftPoints), leftBox); const absoluteRightPoints = denormalize(toArray(newRightPoints), rightBox); const interpolation = []; for (let i = 0; i < absoluteLeftPoints.length; i++) { interpolation.push(absoluteLeftPoints[i] + ( absoluteRightPoints[i] - absoluteLeftPoints[i]) * offset); } return { points: interpolation, occluded: leftPosition.occluded, outside: leftPosition.outside, zOrder: leftPosition.zOrder, }; } // mapping is predicted order of points sourse_idx:target_idx // some points from source and target can absent in mapping // source, target - arrays of points. Target array size >= sourse array size appendMapping(mapping, source, target) { const targetMatched = Object.values(mapping).map(x => +x); const sourceMatched = Object.keys(mapping).map(x => +x); const orderForReceive = []; function findNeighbors(point) { let prev = point; let next = point; if (!targetMatched.length) { // Prevent infinity loop throw new ScriptingError('Interpolation mapping is empty'); } while (!targetMatched.includes(prev)) { prev--; if (prev < 0) { prev = target.length - 1; } } while (!targetMatched.includes(next)) { next++; if (next >= target.length) { next = 0; } } return [prev, next]; } function computeOffset(point, prev, next) { const pathPoints = []; while (prev !== next) { pathPoints.push(target[prev]); prev++; if (prev >= target.length) { prev = 0; } } pathPoints.push(target[next]); let curveLength = 0; let offset = 0; let iCrossed = false; for (let k = 1; k < pathPoints.length; k++) { const p1 = pathPoints[k]; const p2 = pathPoints[k - 1]; const distance = Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2)); if (!iCrossed) { offset += distance; } curveLength += distance; if (target[point] === pathPoints[k]) { iCrossed = true; } } if (!curveLength) { return 0; } return offset / curveLength; } for (let i = 0; i < target.length; i++) { const index = targetMatched.indexOf(i); if (index === -1) { // We have to find a neighbours which have been mapped const [prev, next] = findNeighbors(i); // Now compute edge offset const offset = computeOffset(i, prev, next); // Get point between two neighbors points const prevPoint = target[prev]; const nextPoint = target[next]; const autoPoint = { x: prevPoint.x + (nextPoint.x - prevPoint.x) * offset, y: prevPoint.y + (nextPoint.y - prevPoint.y) * offset, }; // Put it into matched source.push(autoPoint); mapping[source.length - 1] = i; orderForReceive.push(source.length - 1); } else { orderForReceive.push(sourceMatched[index]); } } return orderForReceive; } } class PolygonTrack extends PolyTrack { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); this.shapeType = ObjectShape.POLYGON; for (const shape of Object.values(this.shapes)) { checkNumberOfPoints(this.shapeType, shape.points); } } } class PolylineTrack extends PolyTrack { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); this.shapeType = ObjectShape.POLYLINE; for (const shape of Object.values(this.shapes)) { checkNumberOfPoints(this.shapeType, shape.points); } } } class PointsTrack extends PolyTrack { constructor(data, clientID, color, injection) { super(data, clientID, color, injection); this.shapeType = ObjectShape.POINTS; for (const shape of Object.values(this.shapes)) { checkNumberOfPoints(this.shapeType, shape.points); } } } RectangleTrack.distance = RectangleShape.distance; PolygonTrack.distance = PolygonShape.distance; PolylineTrack.distance = PolylineShape.distance; PointsTrack.distance = PointsShape.distance; module.exports = { RectangleShape, PolygonShape, PolylineShape, PointsShape, RectangleTrack, PolygonTrack, PolylineTrack, PointsTrack, Track, Shape, Tag, objectStateFactory, }; })();