diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb69400edd8..3f15c960877 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,14 +4,12 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-## [1.0.0-beta] - Unreleased
+## [1.0.0-alpha] - Unreleased
### Added
-
### Changed
-- VOC task export now does not use official label map by default, but takes one
- from the source task to avoid primary-class and class part name
- clashing ([#1275](https://github.com/opencv/cvat/issues/1275))
+-
### Deprecated
-
@@ -22,6 +20,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
-
+### Security
+-
+
+## [0.6.1] - 2020-03-21
+### Changed
+- VOC task export now does not use official label map by default, but takes one
+ from the source task to avoid primary-class and class part name
+ clashing ([#1275](https://github.com/opencv/cvat/issues/1275))
+
+### Fixed
+- File names in LabelMe format export are no longer truncated ([#1259](https://github.com/opencv/cvat/issues/1259))
+- `occluded` and `z_order` annotation attributes are now correctly passed to Datumaro ([#1271](https://github.com/opencv/cvat/pull/1271))
+- Annotation-less tasks now can be exported as empty datasets in COCO ([#1277](https://github.com/opencv/cvat/issues/1277))
+- Frame name matching for video annotations import -
+ allowed `frame_XXXXXX[.ext]` format ([#1274](https://github.com/opencv/cvat/pull/1274))
+
### Security
- Bump acorn from 6.3.0 to 6.4.1 in /cvat-ui ([#1270](https://github.com/opencv/cvat/pull/1270))
@@ -48,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- React & Redux & Antd based dashboard
- Yolov3 interpretation script fix and changes to mapping.json
- YOLO format support ([#1151](https://github.com/opencv/cvat/pull/1151))
+- Added support for OpenVINO 2020
### Fixed
- Exception in Git plugin [#826](https://github.com/opencv/cvat/issues/826)
diff --git a/cvat-canvas/README.md b/cvat-canvas/README.md
index 2b9f9201ba7..7fe6049176e 100644
--- a/cvat-canvas/README.md
+++ b/cvat-canvas/README.md
@@ -37,6 +37,19 @@ Canvas itself handles:
EXTREME_POINTS = 'By 4 points'
}
+ enum Mode {
+ IDLE = 'idle',
+ DRAG = 'drag',
+ RESIZE = 'resize',
+ DRAW = 'draw',
+ EDIT = 'edit',
+ MERGE = 'merge',
+ SPLIT = 'split',
+ GROUP = 'group',
+ DRAG_CANVAS = 'drag_canvas',
+ ZOOM_CANVAS = 'zoom_canvas',
+ }
+
interface DrawData {
enabled: boolean;
shapeType?: string;
@@ -70,6 +83,7 @@ Canvas itself handles:
}
interface Canvas {
+ mode(): Mode;
html(): HTMLDivElement;
setZLayer(zLayer: number | null): void;
setup(frameData: any, objectStates: any[]): void;
@@ -128,6 +142,10 @@ Standard JS events are used.
- canvas.dragstop
- canvas.zoomstart
- canvas.zoomstop
+ - canvas.zoom
+ - canvas.fit
+ - canvas.dragshape => {id: number}
+ - canvas.resizeshape => {id: number}
```
### WEB
@@ -135,7 +153,8 @@ Standard JS events are used.
// Create an instance of a canvas
const canvas = new window.canvas.Canvas();
- console.log('Version', window.canvas.CanvasVersion);
+ console.log('Version ', window.canvas.CanvasVersion);
+ console.log('Current mode is ', window.canvas.mode());
// Put canvas to a html container
htmlContainer.appendChild(canvas.html());
diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts
index f6f68d1f977..b372015fdb6 100644
--- a/cvat-canvas/src/typescript/canvasView.ts
+++ b/cvat-canvas/src/typescript/canvasView.ts
@@ -74,7 +74,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
return this.controller.mode;
}
- private onDrawDone(data: object, continueDraw?: boolean): void {
+ private onDrawDone(data: object | null, duration: number, continueDraw?: boolean): void {
if (data) {
const { zLayer } = this.controller;
const event: CustomEvent = new CustomEvent('canvas.drawn', {
@@ -87,6 +87,7 @@ export class CanvasViewImpl implements CanvasView, Listener {
zOrder: zLayer || 0,
},
continue: continueDraw,
+ duration,
},
});
@@ -137,12 +138,13 @@ export class CanvasViewImpl implements CanvasView, Listener {
this.mode = Mode.IDLE;
}
- private onMergeDone(objects: any[]): void {
+ private onMergeDone(objects: any[]| null, duration?: number): void {
if (objects) {
const event: CustomEvent = new CustomEvent('canvas.merged', {
bubbles: false,
cancelable: true,
detail: {
+ duration,
states: objects,
},
});
@@ -708,6 +710,12 @@ export class CanvasViewImpl implements CanvasView, Listener {
} else if ([UpdateReasons.IMAGE_ZOOMED, UpdateReasons.IMAGE_FITTED].includes(reason)) {
this.moveCanvas();
this.transformCanvas();
+ if (reason === UpdateReasons.IMAGE_FITTED) {
+ this.canvas.dispatchEvent(new CustomEvent('canvas.fit', {
+ bubbles: false,
+ cancelable: true,
+ }));
+ }
} else if (reason === UpdateReasons.IMAGE_MOVED) {
this.moveCanvas();
} else if ([UpdateReasons.OBJECTS_UPDATED, UpdateReasons.SET_Z_LAYER].includes(reason)) {
@@ -1166,6 +1174,13 @@ export class CanvasViewImpl implements CanvasView, Listener {
).map((x: number): number => x - offset);
this.drawnStates[state.clientID].points = points;
+ this.canvas.dispatchEvent(new CustomEvent('canvas.dragshape', {
+ bubbles: false,
+ cancelable: true,
+ detail: {
+ id: state.clientID,
+ },
+ }));
this.onEditDone(state, points);
}
});
@@ -1216,6 +1231,13 @@ export class CanvasViewImpl implements CanvasView, Listener {
).map((x: number): number => x - offset);
this.drawnStates[state.clientID].points = points;
+ this.canvas.dispatchEvent(new CustomEvent('canvas.resizeshape', {
+ bubbles: false,
+ cancelable: true,
+ detail: {
+ id: state.clientID,
+ },
+ }));
this.onEditDone(state, points);
}
});
diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts
index 60745f9f3ec..4afe75ec259 100644
--- a/cvat-canvas/src/typescript/drawHandler.ts
+++ b/cvat-canvas/src/typescript/drawHandler.ts
@@ -31,7 +31,8 @@ export interface DrawHandler {
export class DrawHandlerImpl implements DrawHandler {
// callback is used to notify about creating new shape
- private onDrawDone: (data: object, continueDraw?: boolean) => void;
+ private onDrawDone: (data: object | null, duration?: number, continueDraw?: boolean) => void;
+ private startTimestamp: number;
private canvas: SVG.Container;
private text: SVG.Container;
private cursorPosition: {
@@ -180,7 +181,7 @@ export class DrawHandlerImpl implements DrawHandler {
this.onDrawDone({
shapeType,
points: [xtl, ytl, xbr, ybr],
- });
+ }, Date.now() - this.startTimestamp);
}
}).on('drawupdate', (): void => {
this.shapeSizeElement.update(this.drawInstance);
@@ -213,7 +214,7 @@ export class DrawHandlerImpl implements DrawHandler {
this.onDrawDone({
shapeType,
points: [xtl, ytl, xbr, ybr],
- });
+ }, Date.now() - this.startTimestamp);
}
}
}).on('undopoint', (): void => {
@@ -300,7 +301,7 @@ export class DrawHandlerImpl implements DrawHandler {
this.onDrawDone({
shapeType,
points,
- });
+ }, Date.now() - this.startTimestamp);
} else if (shapeType === 'polyline'
&& ((box.xbr - box.xtl) >= consts.SIZE_THRESHOLD
|| (box.ybr - box.ytl) >= consts.SIZE_THRESHOLD)
@@ -308,13 +309,13 @@ export class DrawHandlerImpl implements DrawHandler {
this.onDrawDone({
shapeType,
points,
- });
+ }, Date.now() - this.startTimestamp);
} else if (shapeType === 'points'
&& (e.target as any).getAttribute('points') !== '0,0') {
this.onDrawDone({
shapeType,
points,
- });
+ }, Date.now() - this.startTimestamp);
}
});
}
@@ -365,7 +366,7 @@ export class DrawHandlerImpl implements DrawHandler {
attributes: { ...this.drawData.initialState.attributes },
label: this.drawData.initialState.label,
color: this.drawData.initialState.color,
- }, e.detail.originalEvent.ctrlKey);
+ }, Date.now() - this.startTimestamp, e.detail.originalEvent.ctrlKey);
});
}
@@ -405,7 +406,7 @@ export class DrawHandlerImpl implements DrawHandler {
attributes: { ...this.drawData.initialState.attributes },
label: this.drawData.initialState.label,
color: this.drawData.initialState.color,
- }, e.detail.originalEvent.ctrlKey);
+ }, Date.now() - this.startTimestamp, e.detail.originalEvent.ctrlKey);
});
}
@@ -583,14 +584,16 @@ export class DrawHandlerImpl implements DrawHandler {
this.setupDrawEvents();
}
+ this.startTimestamp = Date.now();
this.initialized = true;
}
public constructor(
- onDrawDone: (data: object, continueDraw?: boolean) => void,
+ onDrawDone: (data: object | null, duration?: number, continueDraw?: boolean) => void,
canvas: SVG.Container,
text: SVG.Container,
) {
+ this.startTimestamp = Date.now();
this.onDrawDone = onDrawDone;
this.canvas = canvas;
this.text = text;
diff --git a/cvat-canvas/src/typescript/mergeHandler.ts b/cvat-canvas/src/typescript/mergeHandler.ts
index efaa4ac09d7..cfb3f78c43d 100644
--- a/cvat-canvas/src/typescript/mergeHandler.ts
+++ b/cvat-canvas/src/typescript/mergeHandler.ts
@@ -15,8 +15,9 @@ export interface MergeHandler {
export class MergeHandlerImpl implements MergeHandler {
// callback is used to notify about merging end
- private onMergeDone: (objects: any[]) => void;
+ private onMergeDone: (objects: any[] | null, duration?: number) => void;
private onFindObject: (event: MouseEvent) => void;
+ private startTimestamp: number;
private canvas: SVG.Container;
private initialized: boolean;
private statesToBeMerged: any[]; // are being merged
@@ -57,6 +58,7 @@ export class MergeHandlerImpl implements MergeHandler {
private initMerging(): void {
this.canvas.node.addEventListener('click', this.onFindObject);
+ this.startTimestamp = Date.now();
this.initialized = true;
}
@@ -66,7 +68,7 @@ export class MergeHandlerImpl implements MergeHandler {
this.release();
if (statesToBeMerged.length > 1) {
- this.onMergeDone(statesToBeMerged);
+ this.onMergeDone(statesToBeMerged, Date.now() - this.startTimestamp);
} else {
this.onMergeDone(null);
// here is a cycle
@@ -77,12 +79,13 @@ export class MergeHandlerImpl implements MergeHandler {
}
public constructor(
- onMergeDone: (objects: any[]) => void,
+ onMergeDone: (objects: any[] | null, duration?: number) => void,
onFindObject: (event: MouseEvent) => void,
canvas: SVG.Container,
) {
this.onMergeDone = onMergeDone;
this.onFindObject = onFindObject;
+ this.startTimestamp = Date.now();
this.canvas = canvas;
this.statesToBeMerged = [];
this.highlightedShapes = {};
diff --git a/cvat-core/package.json b/cvat-core/package.json
index d07e81aa12a..8859adc59bd 100644
--- a/cvat-core/package.json
+++ b/cvat-core/package.json
@@ -34,6 +34,7 @@
"dependencies": {
"axios": "^0.18.0",
"browser-or-node": "^1.2.1",
+ "detect-browser": "^5.0.0",
"error-stack-parser": "^2.0.2",
"form-data": "^2.5.0",
"jest-config": "^24.8.0",
diff --git a/cvat-core/src/api.js b/cvat-core/src/api.js
index 7f9ad9c7448..4eb4e99af00 100644
--- a/cvat-core/src/api.js
+++ b/cvat-core/src/api.js
@@ -14,7 +14,8 @@
function build() {
const PluginRegistry = require('./plugins');
- const User = require('./user');
+ const loggerStorage = require('./logger-storage');
+ const Log = require('./log');
const ObjectState = require('./object-state');
const Statistics = require('./statistics');
const { Job, Task } = require('./session');
@@ -41,6 +42,7 @@ function build() {
ServerError,
} = require('./exceptions');
+ const User = require('./user');
const pjson = require('../package.json');
const config = require('./config');
@@ -419,6 +421,53 @@ function build() {
return result;
},
},
+ /**
+ * Namespace to working with logs
+ * @namespace logger
+ * @memberof module:API.cvat
+ */
+ /**
+ * Method to logger configuration
+ * @method configure
+ * @memberof module:API.cvat.logger
+ * @param {function} isActiveChecker - callback to know if logger
+ * should increase working time or not
+ * @param {object} userActivityCallback - container for a callback
+ * Logger put here a callback to update user activity timer
+ * You can call it outside
+ * @instance
+ * @async
+ * @throws {module:API.cvat.exceptions.PluginError}
+ * @throws {module:API.cvat.exceptions.ArgumentError}
+ */
+
+ /**
+ * Append log to a log collection
+ * Durable logs will have been added after "close" method is called for them
+ * Ignore rules exist for some logs (e.g. zoomImage, changeAttribute)
+ * Payload of ignored logs are shallowly combined to previous logs of the same type
+ * @method log
+ * @memberof module:API.cvat.logger
+ * @param {module:API.cvat.enums.LogType | string} type - log type
+ * @param {Object} [payload = {}] - any other data that will be appended to the log
+ * @param {boolean} [wait = false] - specifies if log is durable
+ * @returns {module:API.cvat.classes.Log}
+ * @instance
+ * @async
+ * @throws {module:API.cvat.exceptions.PluginError}
+ * @throws {module:API.cvat.exceptions.ArgumentError}
+ */
+
+ /**
+ * Save accumulated logs on a server
+ * @method save
+ * @memberof module:API.cvat.logger
+ * @throws {module:API.cvat.exceptions.PluginError}
+ * @throws {module:API.cvat.exceptions.ServerError}
+ * @instance
+ * @async
+ */
+ logger: loggerStorage,
/**
* Namespace contains some changeable configurations
* @namespace config
@@ -432,12 +481,6 @@ function build() {
* @property {string} proxy Axios proxy settings.
* For more details please read here
* @memberof module:API.cvat.config
- * @property {integer} taskID this value is displayed in a logs if available
- * @memberof module:API.cvat.config
- * @property {integer} jobID this value is displayed in a logs if available
- * @memberof module:API.cvat.config
- * @property {integer} clientID read only auto-generated
- * value which is displayed in a logs
* @memberof module:API.cvat.config
*/
get backendAPI() {
@@ -452,21 +495,6 @@ function build() {
set proxy(value) {
config.proxy = value;
},
- get taskID() {
- return config.taskID;
- },
- set taskID(value) {
- config.taskID = value;
- },
- get jobID() {
- return config.jobID;
- },
- set jobID(value) {
- config.jobID = value;
- },
- get clientID() {
- return config.clientID;
- },
},
/**
* Namespace contains some library information e.g. api version
@@ -524,6 +552,7 @@ function build() {
Task,
User,
Job,
+ Log,
Attribute,
Label,
Statistics,
diff --git a/cvat-core/src/config.js b/cvat-core/src/config.js
index e940a214c95..3b9eade8f34 100644
--- a/cvat-core/src/config.js
+++ b/cvat-core/src/config.js
@@ -6,7 +6,4 @@
module.exports = {
backendAPI: 'http://localhost:7000/api/v1',
proxy: false,
- taskID: undefined,
- jobID: undefined,
- clientID: +Date.now().toString().substr(-6),
};
diff --git a/cvat-core/src/enums.js b/cvat-core/src/enums.js
index 2c34290876b..feef4825bdb 100644
--- a/cvat-core/src/enums.js
+++ b/cvat-core/src/enums.js
@@ -103,68 +103,76 @@
});
/**
- * Event types
- * @enum {number}
+ * Logger event types
+ * @enum {string}
* @name LogType
* @memberof module:API.cvat.enums
- * @property {number} pasteObject 0
- * @property {number} changeAttribute 1
- * @property {number} dragObject 2
- * @property {number} deleteObject 3
- * @property {number} pressShortcut 4
- * @property {number} resizeObject 5
- * @property {number} sendLogs 6
- * @property {number} saveJob 7
- * @property {number} jumpFrame 8
- * @property {number} drawObject 9
- * @property {number} changeLabel 10
- * @property {number} sendTaskInfo 11
- * @property {number} loadJob 12
- * @property {number} moveImage 13
- * @property {number} zoomImage 14
- * @property {number} lockObject 15
- * @property {number} mergeObjects 16
- * @property {number} copyObject 17
- * @property {number} propagateObject 18
- * @property {number} undoAction 19
- * @property {number} redoAction 20
- * @property {number} sendUserActivity 21
- * @property {number} sendException 22
- * @property {number} changeFrame 23
- * @property {number} debugInfo 24
- * @property {number} fitImage 25
- * @property {number} rotateImage 26
+ * @property {string} loadJob Load job
+ * @property {string} saveJob Save job
+ * @property {string} restoreJob Restore job
+ * @property {string} uploadAnnotations Upload annotations
+ * @property {string} sendUserActivity Send user activity
+ * @property {string} sendException Send exception
+ * @property {string} sendTaskInfo Send task info
+
+ * @property {string} drawObject Draw object
+ * @property {string} pasteObject Paste object
+ * @property {string} copyObject Copy object
+ * @property {string} propagateObject Propagate object
+ * @property {string} dragObject Drag object
+ * @property {string} resizeObject Resize object
+ * @property {string} deleteObject Delete object
+ * @property {string} lockObject Lock object
+ * @property {string} mergeObjects Merge objects
+ * @property {string} changeAttribute Change attribute
+ * @property {string} changeLabel Change label
+
+ * @property {string} changeFrame Change frame
+ * @property {string} moveImage Move image
+ * @property {string} zoomImage Zoom image
+ * @property {string} fitImage Fit image
+ * @property {string} rotateImage Rotate image
+
+ * @property {string} undoAction Undo action
+ * @property {string} redoAction Redo action
+
+ * @property {string} pressShortcut Press shortcut
+ * @property {string} debugInfo Debug info
* @readonly
*/
- const LogType = {
- pasteObject: 0,
- changeAttribute: 1,
- dragObject: 2,
- deleteObject: 3,
- pressShortcut: 4,
- resizeObject: 5,
- sendLogs: 6,
- saveJob: 7,
- jumpFrame: 8,
- drawObject: 9,
- changeLabel: 10,
- sendTaskInfo: 11,
- loadJob: 12,
- moveImage: 13,
- zoomImage: 14,
- lockObject: 15,
- mergeObjects: 16,
- copyObject: 17,
- propagateObject: 18,
- undoAction: 19,
- redoAction: 20,
- sendUserActivity: 21,
- sendException: 22,
- changeFrame: 23,
- debugInfo: 24,
- fitImage: 25,
- rotateImage: 26,
- };
+ const LogType = Object.freeze({
+ loadJob: 'Load job',
+ saveJob: 'Save job',
+ restoreJob: 'Restore job',
+ uploadAnnotations: 'Upload annotations',
+ sendUserActivity: 'Send user activity',
+ sendException: 'Send exception',
+ sendTaskInfo: 'Send task info',
+
+ drawObject: 'Draw object',
+ pasteObject: 'Paste object',
+ copyObject: 'Copy object',
+ propagateObject: 'Propagate object',
+ dragObject: 'Drag object',
+ resizeObject: 'Resize object',
+ deleteObject: 'Delete object',
+ lockObject: 'Lock object',
+ mergeObjects: 'Merge objects',
+ changeAttribute: 'Change attribute',
+ changeLabel: 'Change label',
+
+ changeFrame: 'Change frame',
+ moveImage: 'Move image',
+ zoomImage: 'Zoom image',
+ fitImage: 'Fit image',
+ rotateImage: 'Rotate image',
+
+ undoAction: 'Undo action',
+ redoAction: 'Redo action',
+
+ pressShortcut: 'Press shortcut',
+ debugInfo: 'Debug info',
+ });
/**
* Types of actions with annotations
@@ -208,7 +216,6 @@
/**
* Array of hex colors
- * @type {module:API.cvat.classes.Loader[]} values
* @name colors
* @memberof module:API.cvat.enums
* @type {string[]}
diff --git a/cvat-core/src/log.js b/cvat-core/src/log.js
new file mode 100644
index 00000000000..68cce5c5d5a
--- /dev/null
+++ b/cvat-core/src/log.js
@@ -0,0 +1,252 @@
+// Copyright (C) 2020 Intel Corporation
+//
+// SPDX-License-Identifier: MIT
+
+/* global
+ require:false
+*/
+
+const { detect } = require('detect-browser');
+const PluginRegistry = require('./plugins');
+const { ArgumentError } = require('./exceptions');
+const { LogType } = require('./enums');
+
+/**
+ * Class representing a single log
+ * @memberof module:API.cvat.classes
+ * @hideconstructor
+*/
+class Log {
+ constructor(logType, payload) {
+ this.onCloseCallback = null;
+
+ this.type = logType;
+ this.payload = { ...payload };
+ this.time = new Date();
+ }
+
+ onClose(callback) {
+ this.onCloseCallback = callback;
+ }
+
+ validatePayload() {
+ if (typeof (this.payload) !== 'object') {
+ throw new ArgumentError('Payload must be an object');
+ }
+
+ try {
+ JSON.stringify(this.payload);
+ } catch (error) {
+ const message = `Log payload must be JSON serializable. ${error.toString()}`;
+ throw new ArgumentError(message);
+ }
+ }
+
+ dump() {
+ const payload = { ...this.payload };
+ const body = {
+ name: this.type,
+ time: this.time.toISOString(),
+ };
+
+ for (const field of ['client_id', 'job_id', 'task_id', 'is_active']) {
+ if (field in payload) {
+ body[field] = payload[field];
+ delete payload[field];
+ }
+ }
+
+ return {
+ ...body,
+ payload,
+ };
+ }
+
+ /**
+ * Method saves a durable log in a storage
+ * Note then you can call close() multiple times
+ * Log duration will be computed based on the latest call
+ * All payloads will be shallowly combined (all top level properties will exist)
+ * @method close
+ * @memberof module:API.cvat.classes.Log
+ * @param {object} [payload] part of payload can be added when close a log
+ * @readonly
+ * @instance
+ * @async
+ * @throws {module:API.cvat.exceptions.PluginError}
+ * @throws {module:API.cvat.exceptions.ArgumentError}
+ */
+ async close(payload = {}) {
+ const result = await PluginRegistry
+ .apiWrapper.call(this, Log.prototype.close, payload);
+ return result;
+ }
+}
+
+Log.prototype.close.implementation = function (payload) {
+ this.payload.duration = Date.now() - this.time.getTime();
+ this.payload = { ...this.payload, ...payload };
+
+ if (this.onCloseCallback) {
+ this.onCloseCallback();
+ }
+};
+
+class LogWithCount extends Log {
+ validatePayload() {
+ Log.prototype.validatePayload.call(this);
+ if (!Number.isInteger(this.payload.count) || this.payload.count < 1) {
+ const message = `The field "count" is required for "${this.type}" log`
+ + 'It must be a positive integer';
+ throw new ArgumentError(message);
+ }
+ }
+}
+
+class LogWithObjectsInfo extends Log {
+ validatePayload() {
+ const generateError = (name, range) => {
+ const message = `The field "${name}" is required for "${this.type}" log. ${range}`;
+ throw new ArgumentError(message);
+ };
+
+ if (!Number.isInteger(this.payload['track count']) || this.payload['track count'] < 0) {
+ generateError('track count', 'It must be an integer not less than 0');
+ }
+
+ if (!Number.isInteger(this.payload['tag count']) || this.payload['tag count'] < 0) {
+ generateError('tag count', 'It must be an integer not less than 0');
+ }
+
+ if (!Number.isInteger(this.payload['object count']) || this.payload['object count'] < 0) {
+ generateError('object count', 'It must be an integer not less than 0');
+ }
+
+ if (!Number.isInteger(this.payload['frame count']) || this.payload['frame count'] < 1) {
+ generateError('frame count', 'It must be an integer not less than 1');
+ }
+
+ if (!Number.isInteger(this.payload['box count']) || this.payload['box count'] < 0) {
+ generateError('box count', 'It must be an integer not less than 0');
+ }
+
+ if (!Number.isInteger(this.payload['polygon count']) || this.payload['polygon count'] < 0) {
+ generateError('polygon count', 'It must be an integer not less than 0');
+ }
+
+ if (!Number.isInteger(this.payload['polyline count']) || this.payload['polyline count'] < 0) {
+ generateError('polyline count', 'It must be an integer not less than 0');
+ }
+
+ if (!Number.isInteger(this.payload['points count']) || this.payload['points count'] < 0) {
+ generateError('points count', 'It must be an integer not less than 0');
+ }
+ }
+}
+
+class LogWithWorkingTime extends Log {
+ validatePayload() {
+ Log.prototype.validatePayload.call(this);
+
+ if (!('working_time' in this.payload)
+ || !typeof (this.payload.working_time) === 'number'
+ || this.payload.working_time < 0
+ ) {
+ const message = `The field "working_time" is required for ${this.type} log. `
+ + 'It must be a number not less than 0';
+ throw new ArgumentError(message);
+ }
+ }
+}
+
+class LogWithExceptionInfo extends Log {
+ validatePayload() {
+ Log.prototype.validatePayload.call(this);
+
+ if (typeof (this.payload.message) !== 'string') {
+ const message = `The field "message" is required for ${this.type} log. `
+ + 'It must be a string';
+ throw new ArgumentError(message);
+ }
+
+ if (typeof (this.payload.filename) !== 'string') {
+ const message = `The field "filename" is required for ${this.type} log. `
+ + 'It must be a string';
+ throw new ArgumentError(message);
+ }
+
+ if (typeof (this.payload.line) !== 'number') {
+ const message = `The field "line" is required for ${this.type} log. `
+ + 'It must be a number';
+ throw new ArgumentError(message);
+ }
+
+ if (typeof (this.payload.column) !== 'number') {
+ const message = `The field "column" is required for ${this.type} log. `
+ + 'It must be a number';
+ throw new ArgumentError(message);
+ }
+
+ if (typeof (this.payload.stack) !== 'string') {
+ const message = `The field "stack" is required for ${this.type} log. `
+ + 'It must be a string';
+ throw new ArgumentError(message);
+ }
+ }
+
+ dump() {
+ const payload = { ...this.payload };
+ const client = detect();
+ const body = {
+ client_id: payload.client_id,
+ name: this.type,
+ time: this.time.toISOString(),
+ message: payload.message,
+ filename: payload.filename,
+ line: payload.line,
+ column: payload.column,
+ stack: payload.stack,
+ system: client.os,
+ client: client.name,
+ version: client.version,
+ };
+
+ delete payload.client_id;
+ delete payload.message;
+ delete payload.filename;
+ delete payload.line;
+ delete payload.column;
+ delete payload.stack;
+
+ return {
+ ...body,
+ payload,
+ };
+ }
+}
+
+function logFactory(logType, payload) {
+ const logsWithCount = [
+ LogType.deleteObject, LogType.mergeObjects, LogType.copyObject,
+ LogType.undoAction, LogType.redoAction,
+ ];
+
+ if (logsWithCount.includes(logType)) {
+ return new LogWithCount(logType, payload);
+ }
+ if ([LogType.sendTaskInfo, LogType.loadJob, LogType.uploadAnnotations].includes(logType)) {
+ return new LogWithObjectsInfo(logType, payload);
+ }
+
+ if (logType === LogType.sendUserActivity) {
+ return new LogWithWorkingTime(logType, payload);
+ }
+
+ if (logType === LogType.sendException) {
+ return new LogWithExceptionInfo(logType, payload);
+ }
+
+ return new Log(logType, payload);
+}
+
+module.exports = logFactory;
diff --git a/cvat-core/src/logger-storage.js b/cvat-core/src/logger-storage.js
new file mode 100644
index 00000000000..ca6860af898
--- /dev/null
+++ b/cvat-core/src/logger-storage.js
@@ -0,0 +1,177 @@
+// Copyright (C) 2020 Intel Corporation
+//
+// SPDX-License-Identifier: MIT
+
+/* global
+ require:false
+*/
+
+const PluginRegistry = require('./plugins');
+const serverProxy = require('./server-proxy');
+const logFactory = require('./log');
+const { ArgumentError } = require('./exceptions');
+const { LogType } = require('./enums');
+
+const WORKING_TIME_THRESHOLD = 100000; // ms, 1.66 min
+
+class LoggerStorage {
+ constructor() {
+ this.clientID = Date.now().toString().substr(-6);
+ this.lastLogTime = Date.now();
+ this.workingTime = 0;
+ this.collection = [];
+ this.ignoreRules = {}; // by event
+ this.isActiveChecker = null;
+
+ this.ignoreRules[LogType.zoomImage] = {
+ lastLog: null,
+ timeThreshold: 1000,
+ ignore(previousLog) {
+ return Date.now() - previousLog.time < this.timeThreshold;
+ },
+ };
+
+ this.ignoreRules[LogType.changeAttribute] = {
+ lastLog: null,
+ ignore(previousLog, currentPayload) {
+ return currentPayload.object_id === previousLog.payload.object_id
+ && currentPayload.id === previousLog.payload.id;
+ },
+ };
+ }
+
+ updateWorkingTime() {
+ if (!this.isActiveChecker || this.isActiveChecker()) {
+ const lastLogTime = Date.now();
+ const diff = lastLogTime - this.lastLogTime;
+ this.workingTime += diff < WORKING_TIME_THRESHOLD ? diff : 0;
+ this.lastLogTime = lastLogTime;
+ }
+ }
+
+ async configure(isActiveChecker, activityHelper) {
+ const result = await PluginRegistry
+ .apiWrapper.call(
+ this, LoggerStorage.prototype.configure,
+ isActiveChecker, activityHelper,
+ );
+ return result;
+ }
+
+ async log(logType, payload = {}, wait = false) {
+ const result = await PluginRegistry
+ .apiWrapper.call(this, LoggerStorage.prototype.log, logType, payload, wait);
+ return result;
+ }
+
+ async save() {
+ const result = await PluginRegistry
+ .apiWrapper.call(this, LoggerStorage.prototype.save);
+ return result;
+ }
+}
+
+LoggerStorage.prototype.configure.implementation = function (
+ isActiveChecker,
+ userActivityCallback,
+) {
+ if (typeof (isActiveChecker) !== 'function') {
+ throw new ArgumentError('isActiveChecker argument must be callable');
+ }
+
+ if (!Array.isArray(userActivityCallback)) {
+ throw new ArgumentError('userActivityCallback argument must be an array');
+ }
+
+ this.isActiveChecker = () => !!isActiveChecker();
+ userActivityCallback.push(this.updateWorkingTime.bind(this));
+};
+
+LoggerStorage.prototype.log.implementation = function (logType, payload, wait) {
+ if (typeof (payload) !== 'object') {
+ throw new ArgumentError('Payload must be an object');
+ }
+
+ if (typeof (wait) !== 'boolean') {
+ throw new ArgumentError('Payload must be an object');
+ }
+
+ if (logType in this.ignoreRules) {
+ const ignoreRule = this.ignoreRules[logType];
+ const { lastLog } = ignoreRule;
+ if (lastLog && ignoreRule.ignore(lastLog, payload)) {
+ lastLog.payload = {
+ ...lastLog.payload,
+ ...payload,
+ };
+
+ this.updateWorkingTime();
+ return ignoreRule.lastLog;
+ }
+ }
+
+ const logPayload = { ...payload };
+ logPayload.client_id = this.clientID;
+ if (this.isActiveChecker) {
+ logPayload.is_active = this.isActiveChecker();
+ }
+
+ const log = logFactory(logType, { ...logPayload });
+ if (logType in this.ignoreRules) {
+ this.ignoreRules[logType].lastLog = log;
+ }
+
+ const pushEvent = () => {
+ this.updateWorkingTime();
+ log.validatePayload();
+ log.onClose(null);
+ this.collection.push(log);
+ };
+
+ if (log.type === LogType.sendException) {
+ serverProxy.server.exception(log.dump()).catch(() => {
+ pushEvent();
+ });
+
+ return log;
+ }
+
+ if (wait) {
+ log.onClose(pushEvent);
+ } else {
+ pushEvent();
+ }
+
+ return log;
+};
+
+LoggerStorage.prototype.save.implementation = async function () {
+ const collectionToSend = [...this.collection];
+ const lastLog = this.collection[this.collection.length - 1];
+
+ const logPayload = {};
+ logPayload.client_id = this.clientID;
+ logPayload.working_time = this.workingTime;
+ if (this.isActiveChecker) {
+ logPayload.is_active = this.isActiveChecker();
+ }
+
+ if (lastLog && lastLog.type === LogType.sendTaskInfo) {
+ logPayload.job_id = lastLog.payload.job_id;
+ logPayload.task_id = lastLog.payload.task_id;
+ }
+
+ const userActivityLog = logFactory(LogType.sendUserActivity, logPayload);
+ collectionToSend.push(userActivityLog);
+
+ await serverProxy.logs.save(collectionToSend.map((log) => log.dump()));
+
+ for (const rule of Object.values(this.ignoreRules)) {
+ rule.lastLog = null;
+ }
+ this.collection = [];
+ this.workingTime = 0;
+ this.lastLogTime = Date.now();
+};
+
+module.exports = new LoggerStorage();
diff --git a/cvat-core/src/logging.js b/cvat-core/src/logging.js
deleted file mode 100644
index f6b52c4ec32..00000000000
--- a/cvat-core/src/logging.js
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
-* Copyright (C) 2019 Intel Corporation
-* SPDX-License-Identifier: MIT
-*/
-
-/* global
- require:false
-*/
-
-(() => {
- const PluginRegistry = require('./plugins');
-
- /**
- * Class describe scheme of a log object
- * @memberof module:API.cvat.classes
- * @hideconstructor
- */
- class Log {
- constructor(logType, continuous, details) {
- this.type = logType;
- this.continuous = continuous;
- this.details = details;
- }
-
- /**
- * Method closes a continue log
- * @method close
- * @memberof module:API.cvat.classes.Log
- * @readonly
- * @instance
- * @async
- * @throws {module:API.cvat.exceptions.PluginError}
- */
- async close() {
- const result = await PluginRegistry
- .apiWrapper.call(this, Log.prototype.close);
- return result;
- }
- }
-
- module.exports = Log;
-})();
diff --git a/cvat-core/src/server-proxy.js b/cvat-core/src/server-proxy.js
index da2675ce4cf..8251328223d 100644
--- a/cvat-core/src/server-proxy.js
+++ b/cvat-core/src/server-proxy.js
@@ -643,6 +643,21 @@
});
}
+ async function saveLogs(logs) {
+ const { backendAPI } = config;
+
+ try {
+ await Axios.post(`${backendAPI}/server/logs`, JSON.stringify(logs), {
+ proxy: config.proxy,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ } catch (errorData) {
+ throw generateError(errorData);
+ }
+ }
+
Object.defineProperties(this, Object.freeze({
server: {
value: Object.freeze({
@@ -705,6 +720,13 @@
}),
writable: false,
},
+
+ logs: {
+ value: Object.freeze({
+ save: saveLogs,
+ }),
+ writable: false,
+ },
}));
}
}
diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js
index 0adfbcf072d..7d66fac9f41 100644
--- a/cvat-core/src/session.js
+++ b/cvat-core/src/session.js
@@ -9,6 +9,7 @@
(() => {
const PluginRegistry = require('./plugins');
+ const loggerStorage = require('./logger-storage');
const serverProxy = require('./server-proxy');
const { getFrame, getRanges, getPreview } = require('./frames');
const { ArgumentError } = require('./exceptions');
@@ -130,16 +131,11 @@
},
writable: true,
}),
- logs: Object.freeze({
+ logger: Object.freeze({
value: {
- async put(logType, details) {
+ async log(logType, payload = {}, wait = false) {
const result = await PluginRegistry
- .apiWrapper.call(this, prototype.logs.put, logType, details);
- return result;
- },
- async save(onUpdate) {
- const result = await PluginRegistry
- .apiWrapper.call(this, prototype.logs.save, onUpdate);
+ .apiWrapper.call(this, prototype.logger.log, logType, payload, wait);
return result;
},
},
@@ -451,33 +447,28 @@
/**
* Namespace is used for an interaction with logs
- * @namespace logs
+ * @namespace logger
* @memberof Session
*/
/**
- * Append log to a log collection.
- * Continue logs will have been added after "close" method is called
- * @method put
- * @memberof Session.logs
- * @param {module:API.cvat.enums.LogType} type a type of a log
- * @param {boolean} continuous log is a continuous log
- * @param {Object} details any others data which will be append to log data
+ * Create a log and add it to a log collection
+ * Durable logs will be added after "close" method is called for them
+ * The fields "task_id" and "job_id" automatically added when add logs
+ * throught a task or a job
+ * Ignore rules exist for some logs (e.g. zoomImage, changeAttribute)
+ * Payload of ignored logs are shallowly combined to previous logs of the same type
+ * @method log
+ * @memberof Session.logger
+ * @param {module:API.cvat.enums.LogType | string} type - log type
+ * @param {Object} [payload = {}] - any other data that will be appended to the log
+ * @param {boolean} [wait = false] - specifies if log is durable
* @returns {module:API.cvat.classes.Log}
* @instance
* @async
* @throws {module:API.cvat.exceptions.PluginError}
* @throws {module:API.cvat.exceptions.ArgumentError}
*/
- /**
- * Save accumulated logs on a server
- * @method save
- * @memberof Session.logs
- * @throws {module:API.cvat.exceptions.PluginError}
- * @throws {module:API.cvat.exceptions.ServerError}
- * @instance
- * @async
- */
/**
* Namespace is used for an interaction with actions
@@ -718,6 +709,10 @@
ranges: Object.getPrototypeOf(this).frames.ranges.bind(this),
preview: Object.getPrototypeOf(this).frames.preview.bind(this),
};
+
+ this.logger = {
+ log: Object.getPrototypeOf(this).logger.log.bind(this),
+ };
}
/**
@@ -1266,6 +1261,10 @@
ranges: Object.getPrototypeOf(this).frames.ranges.bind(this),
preview: Object.getPrototypeOf(this).frames.preview.bind(this),
};
+
+ this.logger = {
+ log: Object.getPrototypeOf(this).logger.log.bind(this),
+ };
}
/**
@@ -1518,6 +1517,11 @@
return result;
};
+ Job.prototype.logger.log.implementation = async function (logType, payload, wait) {
+ const result = await this.task.logger.log(logType, { ...payload, job_id: this.id }, wait);
+ return result;
+ };
+
Task.prototype.save.implementation = async function saveTaskImplementation(onUpdate) {
// TODO: Add ability to change an owner and an assignee
if (typeof (this.id) !== 'undefined') {
@@ -1756,4 +1760,9 @@
const result = getActions(this);
return result;
};
+
+ Task.prototype.logger.log.implementation = async function (logType, payload, wait) {
+ const result = await loggerStorage.log(logType, { ...payload, task_id: this.id }, wait);
+ return result;
+ };
})();
diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json
index 5ee86576af3..5c0d7ae1715 100644
--- a/cvat-ui/package-lock.json
+++ b/cvat-ui/package-lock.json
@@ -3900,6 +3900,14 @@
"is-arrayish": "^0.2.1"
}
},
+ "error-stack-parser": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.0.6.tgz",
+ "integrity": "sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ==",
+ "requires": {
+ "stackframe": "^1.1.1"
+ }
+ },
"es-abstract": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.16.0.tgz",
@@ -11291,6 +11299,11 @@
"integrity": "sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w==",
"dev": true
},
+ "stackframe": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.1.1.tgz",
+ "integrity": "sha512-0PlYhdKh6AfFxRyK/v+6/k+/mMfyiEBbTM5L94D0ZytQnJ166wuwoTYLHFWGbs2dpA8Rgq763KGWmN1EQEYHRQ=="
+ },
"static-extend": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
@@ -12075,9 +12088,9 @@
"dev": true
},
"ua-parser-js": {
- "version": "0.7.20",
- "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.20.tgz",
- "integrity": "sha512-8OaIKfzL5cpx8eCMAhhvTlft8GYF8b2eQr6JkCyVdrgjcytyOmPCXrqXFcUnhonRpLlh5yxEZVohm6mzaowUOw=="
+ "version": "0.7.21",
+ "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.21.tgz",
+ "integrity": "sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ=="
},
"uglify-js": {
"version": "3.4.10",
diff --git a/cvat-ui/package.json b/cvat-ui/package.json
index 9204b37a6a5..6b6907d2e9f 100644
--- a/cvat-ui/package.json
+++ b/cvat-ui/package.json
@@ -59,6 +59,7 @@
"antd": "^3.25.2",
"copy-to-clipboard": "^3.2.0",
"dotenv-webpack": "^1.7.0",
+ "error-stack-parser": "^2.0.6",
"moment": "^2.24.0",
"prop-types": "^15.7.2",
"react": "^16.9.0",
diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts
index 8040d5e05aa..0080d48538f 100644
--- a/cvat-ui/src/actions/annotation-actions.ts
+++ b/cvat-ui/src/actions/annotation-actions.ts
@@ -22,6 +22,7 @@ import {
} from 'reducers/interfaces';
import getCore from 'cvat-core';
+import logger, { LogType } from 'cvat-logger';
import { RectDrawingMethod } from 'cvat-canvas';
import { getCVATStore } from 'cvat-store';
@@ -77,10 +78,14 @@ function receiveAnnotationsParameters(): AnnotationsParameters {
};
}
-function computeZRange(states: any[]): number[] {
+export function computeZRange(states: any[]): number[] {
let minZ = states.length ? states[0].zOrder : 0;
let maxZ = states.length ? states[0].zOrder : 0;
states.forEach((state: any): void => {
+ if (state.objectType === ObjectType.TAG) {
+ return;
+ }
+
minZ = Math.min(minZ, state.zOrder);
maxZ = Math.max(maxZ, state.zOrder);
});
@@ -88,6 +93,23 @@ function computeZRange(states: any[]): number[] {
return [minZ, maxZ];
}
+async function jobInfoGenerator(job: any): Promise> {
+ const { total } = await job.annotations.statistics();
+ return {
+ 'frame count': job.stopFrame - job.startFrame + 1,
+ 'track count': total.rectangle.shape + total.rectangle.track
+ + total.polygon.shape + total.polygon.track
+ + total.polyline.shape + total.polyline.track
+ + total.points.shape + total.points.track,
+ 'object count': total.total,
+ 'box count': total.rectangle.shape + total.rectangle.track,
+ 'polygon count': total.polygon.shape + total.polygon.track,
+ 'polyline count': total.polyline.shape + total.polyline.track,
+ 'points count': total.points.shape + total.points.track,
+ 'tag count': total.tags,
+ };
+}
+
export enum AnnotationActionTypes {
GET_JOB = 'GET_JOB',
GET_JOB_SUCCESS = 'GET_JOB_SUCCESS',
@@ -165,6 +187,28 @@ export enum AnnotationActionTypes {
ADD_Z_LAYER = 'ADD_Z_LAYER',
SEARCH_ANNOTATIONS_FAILED = 'SEARCH_ANNOTATIONS_FAILED',
CHANGE_WORKSPACE = 'CHANGE_WORKSPACE',
+ SAVE_LOGS_SUCCESS = 'SAVE_LOGS_SUCCESS',
+ SAVE_LOGS_FAILED = 'SAVE_LOGS_FAILED',
+}
+
+export function saveLogsAsync():
+ThunkAction, {}, {}, AnyAction> {
+ return async (dispatch: ActionCreator) => {
+ try {
+ await logger.save();
+ dispatch({
+ type: AnnotationActionTypes.SAVE_LOGS_SUCCESS,
+ payload: {},
+ });
+ } catch (error) {
+ dispatch({
+ type: AnnotationActionTypes.SAVE_LOGS_FAILED,
+ payload: {
+ error,
+ },
+ });
+ }
+ };
}
export function changeWorkspace(workspace: Workspace): AnyAction {
@@ -192,8 +236,7 @@ export function switchZLayer(cur: number): AnyAction {
};
}
-export function fetchAnnotationsAsync():
-ThunkAction, {}, {}, AnyAction> {
+export function fetchAnnotationsAsync(): ThunkAction, {}, {}, AnyAction> {
return async (dispatch: ActionCreator): Promise => {
try {
const {
@@ -250,14 +293,21 @@ export function undoActionAsync(sessionInstance: any, frame: number):
ThunkAction, {}, {}, AnyAction> {
return async (dispatch: ActionCreator): Promise => {
try {
+ const state = getStore().getState();
const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters();
// TODO: use affected IDs as an optimization
+ const [undoName] = state.annotation.annotations.history.undo.slice(-1);
+ const undoLog = await sessionInstance.logger.log(LogType.undoAction, {
+ name: undoName,
+ count: 1,
+ }, true);
await sessionInstance.actions.undo();
const history = await sessionInstance.actions.get();
const states = await sessionInstance.annotations
.get(frame, showAllInterpolationTracks, filters);
const [minZ, maxZ] = computeZRange(states);
+ await undoLog.close();
dispatch({
type: AnnotationActionTypes.UNDO_ACTION_SUCCESS,
@@ -283,14 +333,21 @@ export function redoActionAsync(sessionInstance: any, frame: number):
ThunkAction, {}, {}, AnyAction> {
return async (dispatch: ActionCreator): Promise => {
try {
+ const state = getStore().getState();
const { filters, showAllInterpolationTracks } = receiveAnnotationsParameters();
// TODO: use affected IDs as an optimization
+ const [redoName] = state.annotation.annotations.history.redo.slice(-1);
+ const redoLog = await sessionInstance.logger.log(LogType.redoAction, {
+ name: redoName,
+ count: 1,
+ }, true);
await sessionInstance.actions.redo();
const history = await sessionInstance.actions.get();
const states = await sessionInstance.annotations
.get(frame, showAllInterpolationTracks, filters);
const [minZ, maxZ] = computeZRange(states);
+ await redoLog.close();
dispatch({
type: AnnotationActionTypes.REDO_ACTION_SUCCESS,
@@ -373,6 +430,12 @@ ThunkAction, {}, {}, AnyAction> {
const frame = state.annotation.player.frame.number;
await job.annotations.upload(file, loader);
+ await job.logger.log(
+ LogType.uploadAnnotations, {
+ ...(await jobInfoGenerator(job)),
+ },
+ );
+
// One more update to escape some problems
// in canvas when shape with the same
// clientID has different type (polygon, rectangle) for example
@@ -499,6 +562,9 @@ export function propagateObjectAsync(
frame: from,
};
+ await sessionInstance.logger.log(
+ LogType.propagateObject, { count: to - from + 1 },
+ );
const states = [];
for (let frame = from; frame <= to; frame++) {
copy.frame = frame;
@@ -549,6 +615,7 @@ export function removeObjectAsync(sessionInstance: any, objectState: any, force:
ThunkAction, {}, {}, AnyAction> {
return async (dispatch: ActionCreator): Promise => {
try {
+ await sessionInstance.logger.log(LogType.deleteObject, { count: 1 });
const removed = await objectState.delete(force);
const history = await sessionInstance.actions.get();
@@ -584,6 +651,9 @@ export function editShape(enabled: boolean): AnyAction {
}
export function copyShape(objectState: any): AnyAction {
+ const job = getStore().getState().annotation.job.instance;
+ job.logger.log(LogType.copyObject, { count: 1 });
+
return {
type: AnnotationActionTypes.COPY_SHAPE,
payload: {
@@ -687,6 +757,12 @@ ThunkAction, {}, {}, AnyAction> {
payload: {},
});
+ await job.logger.log(
+ LogType.changeFrame, {
+ from: frame,
+ to: toFrame,
+ },
+ );
const data = await job.frames.get(toFrame, fillBuffer, frameStep);
const states = await job.annotations.get(toFrame, showAllInterpolationTracks, filters);
const [minZ, maxZ] = computeZRange(states);
@@ -707,6 +783,7 @@ ThunkAction, {}, {}, AnyAction> {
}
const delay = Math.max(0, Math.round(1000 / frameSpeed)
- currentTime + (state.annotation.player.frame.changeTime as number));
+
dispatch({
type: AnnotationActionTypes.CHANGE_FRAME_SUCCESS,
payload: {
@@ -734,14 +811,33 @@ ThunkAction, {}, {}, AnyAction> {
export function rotateCurrentFrame(rotation: Rotation): AnyAction {
const state: CombinedState = getStore().getState();
- const { number: frameNumber } = state.annotation.player.frame;
- const { startFrame } = state.annotation.job.instance;
- const { frameAngles } = state.annotation.player;
- const { rotateAll } = state.settings.player;
+ const {
+ annotation: {
+ player: {
+ frame: {
+ number: frameNumber,
+ },
+ frameAngles,
+ },
+ job: {
+ instance: job,
+ instance: {
+ startFrame,
+ },
+ },
+ },
+ settings: {
+ player: {
+ rotateAll,
+ },
+ },
+ } = state;
const frameAngle = (frameAngles[frameNumber - startFrame]
+ (rotation === Rotation.CLOCKWISE90 ? 90 : 270)) % 360;
+ job.logger.log(LogType.rotateImage, { angle: frameAngle });
+
return {
type: AnnotationActionTypes.ROTATE_FRAME,
payload: {
@@ -791,11 +887,6 @@ export function getJobAsync(
initialFilters: string[],
): ThunkAction, {}, {}, AnyAction> {
return async (dispatch: ActionCreator): Promise => {
- dispatch({
- type: AnnotationActionTypes.GET_JOB,
- payload: {},
- });
-
try {
const state: CombinedState = getStore().getState();
const filters = initialFilters;
@@ -808,6 +899,18 @@ export function getJobAsync(
});
}
+ dispatch({
+ type: AnnotationActionTypes.GET_JOB,
+ payload: {},
+ });
+
+ const loadJobEvent = await logger.log(
+ LogType.loadJob, {
+ task_id: tid,
+ job_id: jid,
+ }, true,
+ );
+
// Check state if the task is already there
let task = state.tasks.current
.filter((_task: Task) => _task.instance.id === tid)
@@ -832,6 +935,8 @@ export function getJobAsync(
const [minZ, maxZ] = computeZRange(states);
const colors = [...cvat.enums.colors];
+ loadJobEvent.close(await jobInfoGenerator(job));
+
dispatch({
type: AnnotationActionTypes.GET_JOB_SUCCESS,
payload: {
@@ -865,6 +970,10 @@ ThunkAction, {}, {}, AnyAction> {
});
try {
+ const saveJobEvent = await sessionInstance.logger.log(
+ LogType.saveJob, {}, true,
+ );
+
await sessionInstance.annotations.save((status: string) => {
dispatch({
type: AnnotationActionTypes.SAVE_UPDATE_ANNOTATIONS_STATUS,
@@ -874,6 +983,13 @@ ThunkAction, {}, {}, AnyAction> {
});
});
+ await saveJobEvent.close();
+ await sessionInstance.logger.log(
+ LogType.sendTaskInfo,
+ await jobInfoGenerator(sessionInstance),
+ );
+ dispatch(saveLogsAsync());
+
dispatch({
type: AnnotationActionTypes.SAVE_ANNOTATIONS_SUCCESS,
payload: {},
@@ -889,6 +1005,7 @@ ThunkAction, {}, {}, AnyAction> {
};
}
+// used to reproduce the latest drawing (in case of tags just creating) by using N
export function rememberObject(
objectType: ObjectType,
labelID: number,
diff --git a/cvat-ui/src/actions/boundaries-actions.ts b/cvat-ui/src/actions/boundaries-actions.ts
new file mode 100644
index 00000000000..fa2faba0308
--- /dev/null
+++ b/cvat-ui/src/actions/boundaries-actions.ts
@@ -0,0 +1,88 @@
+// Copyright (C) 2020 Intel Corporation
+//
+// SPDX-License-Identifier: MIT
+
+import {
+ ActionUnion,
+ createAction,
+ ThunkAction,
+ ThunkDispatch,
+} from 'utils/redux';
+import getCore from 'cvat-core';
+import { LogType } from 'cvat-logger';
+import { computeZRange } from './annotation-actions';
+
+const cvat = getCore();
+
+export enum BoundariesActionTypes {
+ RESET_AFTER_ERROR = 'RESET_AFTER_ERROR',
+ THROW_RESET_ERROR = 'THROW_RESET_ERROR',
+}
+
+export const boundariesActions = {
+ resetAfterError: (
+ job: any,
+ states: any[],
+ frameNumber: number,
+ frameData: any | null,
+ minZ: number,
+ maxZ: number,
+ colors: string[],
+ ) => createAction(BoundariesActionTypes.RESET_AFTER_ERROR, {
+ job,
+ states,
+ frameNumber,
+ frameData,
+ minZ,
+ maxZ,
+ colors,
+ }),
+ throwResetError: () => createAction(BoundariesActionTypes.THROW_RESET_ERROR),
+};
+
+export function resetAfterErrorAsync(): ThunkAction {
+ return async (dispatch: ThunkDispatch, getState): Promise => {
+ try {
+ const state = getState();
+ const job = state.annotation.job.instance;
+
+ if (job) {
+ const currentFrame = state.annotation.player.frame.number;
+ const { showAllInterpolationTracks } = state.settings.workspace;
+ const frameNumber = Math.max(Math.min(job.stopFrame, currentFrame), job.startFrame);
+
+ const states = await job.annotations
+ .get(frameNumber, showAllInterpolationTracks, []);
+ const frameData = await job.frames.get(frameNumber);
+ const [minZ, maxZ] = computeZRange(states);
+ const colors = [...cvat.enums.colors];
+
+ await job.logger.log(LogType.restoreJob);
+
+ dispatch(boundariesActions.resetAfterError(
+ job,
+ states,
+ frameNumber,
+ frameData,
+ minZ,
+ maxZ,
+ colors,
+ ));
+ } else {
+ dispatch(boundariesActions.resetAfterError(
+ null,
+ [],
+ 0,
+ null,
+ 0,
+ 0,
+ [],
+ ));
+ }
+ } catch (error) {
+ dispatch(boundariesActions.throwResetError());
+ }
+ };
+}
+
+export type boundariesActions = ActionUnion;
diff --git a/cvat-ui/src/components/annotation-page/annotation-page.tsx b/cvat-ui/src/components/annotation-page/annotation-page.tsx
index a096afd2fde..db5b6500e9b 100644
--- a/cvat-ui/src/components/annotation-page/annotation-page.tsx
+++ b/cvat-ui/src/components/annotation-page/annotation-page.tsx
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: MIT
import './styles.scss';
-import React from 'react';
+import React, { useEffect } from 'react';
import {
Layout,
@@ -21,18 +21,24 @@ interface Props {
job: any | null | undefined;
fetching: boolean;
getJob(): void;
+ saveLogs(): void;
workspace: Workspace;
}
-
export default function AnnotationPageComponent(props: Props): JSX.Element {
const {
job,
fetching,
getJob,
+ saveLogs,
workspace,
} = props;
+ useEffect(() => {
+ saveLogs();
+ return saveLogs;
+ }, []);
+
if (job === null) {
if (!fetching) {
getJob();
diff --git a/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx b/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx
index d19b94ef9de..74cd0beca38 100644
--- a/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx
+++ b/cvat-ui/src/components/annotation-page/attribute-annotation-workspace/attribute-annotation-sidebar/attribute-annotation-sidebar.tsx
@@ -5,12 +5,15 @@
import React, { useState, useEffect } from 'react';
import { GlobalHotKeys, KeyMap } from 'react-hotkeys';
import { connect } from 'react-redux';
+import { Action } from 'redux';
+import { ThunkDispatch } from 'redux-thunk';
import Layout, { SiderProps } from 'antd/lib/layout';
import { SelectValue } from 'antd/lib/select';
import { CheckboxChangeEvent } from 'antd/lib/checkbox';
import { Row, Col } from 'antd/lib/grid';
import Text from 'antd/lib/typography/Text';
+import { LogType } from 'cvat-logger';
import {
activateObject as activateObjectAction,
updateAnnotationsAsync,
@@ -28,6 +31,7 @@ interface StateToProps {
activatedAttributeID: number | null;
states: any[];
labels: any[];
+ jobInstance: any;
}
interface DispatchToProps {
@@ -48,12 +52,14 @@ function mapStateToProps(state: CombinedState): StateToProps {
states,
},
job: {
+ instance: jobInstance,
labels,
},
},
} = state;
return {
+ jobInstance,
labels,
activatedStateID,
activatedAttributeID,
@@ -61,7 +67,7 @@ function mapStateToProps(state: CombinedState): StateToProps {
};
}
-function mapDispatchToProps(dispatch: any): DispatchToProps {
+function mapDispatchToProps(dispatch: ThunkDispatch): DispatchToProps {
return {
activateObject(clientID: number, attrID: number): void {
dispatch(activateObjectAction(clientID, attrID));
@@ -78,6 +84,7 @@ function AttributeAnnotationSidebar(props: StateToProps & DispatchToProps): JSX.
states,
activatedStateID,
activatedAttributeID,
+ jobInstance,
updateAnnotations,
activateObject,
} = props;
@@ -267,6 +274,13 @@ function AttributeAnnotationSidebar(props: StateToProps & DispatchToProps): JSX.
currentValue={activeObjectState.attributes[activeAttribute.id]}
onChange={(value: string) => {
const { attributes } = activeObjectState;
+ jobInstance.logger.log(
+ LogType.changeAttribute, {
+ id: activeAttribute.id,
+ object_id: activeObjectState.clientID,
+ value,
+ },
+ );
attributes[activeAttribute.id] = value;
activeObjectState.attributes = attributes;
updateAnnotations([activeObjectState]);
diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx
index 91a2e20e698..16bc2e3ec14 100644
--- a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx
+++ b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx
@@ -9,6 +9,7 @@ import Layout from 'antd/lib/layout';
import Icon from 'antd/lib/icon';
import Tooltip from 'antd/lib/tooltip';
+import { LogType } from 'cvat-logger';
import { Canvas } from 'cvat-canvas';
import getCore from 'cvat-core';
import {
@@ -214,6 +215,10 @@ export default class CanvasWrapperComponent extends React.PureComponent {
canvasInstance.html().removeEventListener('canvas.deactivated', this.onCanvasShapeDeactivated);
canvasInstance.html().removeEventListener('canvas.moved', this.onCanvasCursorMoved);
+ canvasInstance.html().removeEventListener('canvas.zoom', this.onCanvasZoomChanged);
+ canvasInstance.html().removeEventListener('canvas.fit', this.onCanvasImageFitted);
+ canvasInstance.html().removeEventListener('canvas.dragshape', this.onCanvasShapeDragged);
+ canvasInstance.html().removeEventListener('canvas.resizeshape', this.onCanvasShapeResized);
canvasInstance.html().removeEventListener('canvas.clicked', this.onCanvasShapeClicked);
canvasInstance.html().removeEventListener('canvas.drawn', this.onCanvasShapeDrawn);
canvasInstance.html().removeEventListener('canvas.merged', this.onCanvasObjectsMerged);
@@ -237,20 +242,18 @@ export default class CanvasWrapperComponent extends React.PureComponent {
onShapeDrawn();
}
- const { state } = event.detail;
- if (!state.objectType) {
- state.objectType = activeObjectType;
- }
-
- if (!state.label) {
- [state.label] = jobInstance.task.labels
- .filter((label: any) => label.id === activeLabelID);
- }
-
- if (typeof (state.occluded) === 'undefined') {
- state.occluded = false;
+ const { state, duration } = event.detail;
+ const isDrawnFromScratch = !state.label;
+ if (isDrawnFromScratch) {
+ jobInstance.logger.log(LogType.drawObject, { count: 1, duration });
+ } else {
+ jobInstance.logger.log(LogType.pasteObject, { count: 1, duration });
}
+ state.objectType = state.objectType || activeObjectType;
+ state.label = state.label || jobInstance.task.labels
+ .filter((label: any) => label.id === activeLabelID)[0];
+ state.occluded = state.occluded || false;
state.frame = frame;
const objectState = new cvat.classes.ObjectState(state);
onCreateAnnotations(jobInstance, frame, [objectState]);
@@ -266,7 +269,11 @@ export default class CanvasWrapperComponent extends React.PureComponent {
onMergeObjects(false);
- const { states } = event.detail;
+ const { states, duration } = event.detail;
+ jobInstance.logger.log(LogType.mergeObjects, {
+ duration,
+ count: states.length,
+ });
onMergeAnnotations(jobInstance, frame, states);
};
@@ -324,6 +331,28 @@ export default class CanvasWrapperComponent extends React.PureComponent {
onUpdateContextMenu(activatedStateID !== null, e.clientX, e.clientY);
};
+ private onCanvasShapeDragged = (e: any): void => {
+ const { jobInstance } = this.props;
+ const { id } = e.detail;
+ jobInstance.logger.log(LogType.dragObject, { id });
+ };
+
+ private onCanvasShapeResized = (e: any): void => {
+ const { jobInstance } = this.props;
+ const { id } = e.detail;
+ jobInstance.logger.log(LogType.resizeObject, { id });
+ };
+
+ private onCanvasImageFitted = (): void => {
+ const { jobInstance } = this.props;
+ jobInstance.logger.log(LogType.fitImage);
+ };
+
+ private onCanvasZoomChanged = (): void => {
+ const { jobInstance } = this.props;
+ jobInstance.logger.log(LogType.zoomImage);
+ };
+
private onCanvasShapeClicked = (e: any): void => {
const { clientID } = e.detail.state;
const sidebarItem = window.document
@@ -581,6 +610,10 @@ export default class CanvasWrapperComponent extends React.PureComponent {
canvasInstance.html().addEventListener('canvas.deactivated', this.onCanvasShapeDeactivated);
canvasInstance.html().addEventListener('canvas.moved', this.onCanvasCursorMoved);
+ canvasInstance.html().addEventListener('canvas.zoom', this.onCanvasZoomChanged);
+ canvasInstance.html().addEventListener('canvas.fit', this.onCanvasImageFitted);
+ canvasInstance.html().addEventListener('canvas.dragshape', this.onCanvasShapeDragged);
+ canvasInstance.html().addEventListener('canvas.resizeshape', this.onCanvasShapeResized);
canvasInstance.html().addEventListener('canvas.clicked', this.onCanvasShapeClicked);
canvasInstance.html().addEventListener('canvas.drawn', this.onCanvasShapeDrawn);
canvasInstance.html().addEventListener('canvas.merged', this.onCanvasObjectsMerged);
diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx
index 91795797980..c168a0288ee 100644
--- a/cvat-ui/src/components/cvat-app.tsx
+++ b/cvat-ui/src/components/cvat-app.tsx
@@ -8,13 +8,11 @@ import React from 'react';
import { Switch, Route, Redirect } from 'react-router';
import { withRouter, RouteComponentProps } from 'react-router-dom';
import { GlobalHotKeys, KeyMap, configure } from 'react-hotkeys';
+import Spin from 'antd/lib/spin';
+import Layout from 'antd/lib/layout';
+import notification from 'antd/lib/notification';
-import {
- Spin,
- Layout,
- notification,
-} from 'antd';
-
+import GlobalErrorBoundary from 'components/global-error-boundary/global-error-boundary';
import ShorcutsDialog from 'components/shortcuts-dialog/shortcuts-dialog';
import SettingsPageContainer from 'containers/settings-page/settings-page';
import TasksPageContainer from 'containers/tasks-page/tasks-page';
@@ -27,6 +25,7 @@ import LoginPageContainer from 'containers/login-page/login-page';
import RegisterPageContainer from 'containers/register-page/register-page';
import HeaderContainer from 'containers/header/header';
+import getCore from 'cvat-core';
import { NotificationsState } from 'reducers/interfaces';
interface CVATAppProps {
@@ -39,6 +38,7 @@ interface CVATAppProps {
resetMessages: () => void;
switchShortcutsDialog: () => void;
userInitialized: boolean;
+ userFetching: boolean;
pluginsInitialized: boolean;
pluginsFetching: boolean;
formatsInitialized: boolean;
@@ -56,18 +56,29 @@ interface CVATAppProps {
class CVATApplication extends React.PureComponent {
public componentDidMount(): void {
+ const core = getCore();
const { verifyAuthorized } = this.props;
configure({ ignoreRepeatedEventsWhenKeyHeldDown: false });
+
+ // Logger configuration
+ const userActivityCallback: (() => void)[] = [];
+ window.addEventListener('click', () => {
+ userActivityCallback.forEach((handler) => handler());
+ });
+ core.logger.configure(() => window.document.hasFocus, userActivityCallback);
+
verifyAuthorized();
}
public componentDidUpdate(): void {
const {
+ verifyAuthorized,
loadFormats,
loadUsers,
loadAbout,
initPlugins,
userInitialized,
+ userFetching,
formatsInitialized,
formatsFetching,
usersInitialized,
@@ -82,8 +93,12 @@ class CVATApplication extends React.PureComponent
-
-
-
-
-
-
-
-
-
-
- { withModels
- && }
- { installedAutoAnnotation
- && }
-
-
-
- {/* eslint-disable-next-line */}
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+ {withModels
+ && }
+ {installedAutoAnnotation
+ && }
+
+
+
+ {/* eslint-disable-next-line */}
+
+
+
+
);
}
return (
-
-
-
-
-
+
+
+
+
+
+
+
);
}
diff --git a/cvat-ui/src/components/global-error-boundary/global-error-boundary.tsx b/cvat-ui/src/components/global-error-boundary/global-error-boundary.tsx
new file mode 100644
index 00000000000..3c132fd9f9b
--- /dev/null
+++ b/cvat-ui/src/components/global-error-boundary/global-error-boundary.tsx
@@ -0,0 +1,223 @@
+// Copyright (C) 2020 Intel Corporation
+//
+// SPDX-License-Identifier: MIT
+
+import './styles.scss';
+import React from 'react';
+import { connect } from 'react-redux';
+import { Action } from 'redux';
+import { ThunkDispatch } from 'redux-thunk';
+import Result from 'antd/lib/result';
+import Text from 'antd/lib/typography/Text';
+import Paragraph from 'antd/lib/typography/Paragraph';
+import Collapse from 'antd/lib/collapse';
+import TextArea from 'antd/lib/input/TextArea';
+import Tooltip from 'antd/lib/tooltip';
+import copy from 'copy-to-clipboard';
+import ErrorStackParser from 'error-stack-parser';
+
+import { resetAfterErrorAsync } from 'actions/boundaries-actions';
+import { CombinedState } from 'reducers/interfaces';
+import logger, { LogType } from 'cvat-logger';
+
+interface StateToProps {
+ job: any | null;
+ serverVersion: string;
+ coreVersion: string;
+ canvasVersion: string;
+ uiVersion: string;
+}
+
+interface DispatchToProps {
+ restore(): void;
+}
+
+interface State {
+ hasError: boolean;
+ error: Error | null;
+}
+
+function mapStateToProps(state: CombinedState): StateToProps {
+ const {
+ annotation: {
+ job: {
+ instance: job,
+ },
+ },
+ about: {
+ server,
+ packageVersion,
+ },
+ } = state;
+
+ return {
+ job,
+ serverVersion: server.version as string,
+ coreVersion: packageVersion.core,
+ canvasVersion: packageVersion.canvas,
+ uiVersion: packageVersion.ui,
+ };
+}
+
+function mapDispatchToProps(dispatch: ThunkDispatch): DispatchToProps {
+ return {
+ restore(): void {
+ dispatch(resetAfterErrorAsync());
+ },
+ };
+}
+
+
+type Props = StateToProps & DispatchToProps;
+class GlobalErrorBoundary extends React.PureComponent {
+ public constructor(props: Props) {
+ super(props);
+ this.state = {
+ hasError: false,
+ error: null,
+ };
+ }
+
+ static getDerivedStateFromError(error: Error): State {
+ return {
+ hasError: true,
+ error,
+ };
+ }
+
+ public componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
+ const { job } = this.props;
+ const parsed = ErrorStackParser.parse(error);
+
+ const logPayload = {
+ filename: parsed[0].fileName,
+ line: parsed[0].lineNumber,
+ message: error.message,
+ column: parsed[0].columnNumber,
+ stack: error.stack,
+ componentStack: errorInfo.componentStack,
+ };
+
+ if (job) {
+ job.logger.log(LogType.sendException, logPayload);
+ } else {
+ logger.log(LogType.sendException, logPayload);
+ }
+ }
+
+ public render(): React.ReactNode {
+ const {
+ restore,
+ job,
+ serverVersion,
+ coreVersion,
+ canvasVersion,
+ uiVersion,
+ } = this.props;
+
+ const { hasError, error } = this.state;
+
+ const restoreGlobalState = (): void => {
+ this.setState({
+ error: null,
+ hasError: false,
+ });
+
+ restore();
+ };
+
+ if (hasError && error) {
+ const message = `${error.name}\n${error.message}\n\n${error.stack}`;
+ return (
+
+
+
+
+ What has happened?
+ Program error has just occured
+
+
+
+
+
+
+
+
+
+
+ What should I do?
+
+
+
+
+ {/* eslint-disable-next-line */}
+ {copy(message)}}> Copy
+
+ the error message to clipboard
+
+
+ Notify an administrator or submit the issue directly on
+ GitHub.
+ Please, provide also:
+
+ Steps to reproduce the issue
+ Your operating system and browser version
+ CVAT version
+
+
+ Server:
+ {serverVersion}
+
+
+ Core:
+ {coreVersion}
+
+
+ Canvas:
+ {canvasVersion}
+
+
+ UI:
+ {uiVersion}
+
+
+
+
+ {job ? (
+
+ Press
+ {/* eslint-disable-next-line */}
+ here
+ if you wish CVAT tried to restore your
+ annotation progress or
+ {/* eslint-disable-next-line */}
+ window.location.reload()}> update
+ the page
+
+ ) : (
+
+ {/* eslint-disable-next-line */}
+ window.location.reload()}>Update
+ the page
+
+ )}
+
+
+
+
+ );
+ }
+
+ const { children } = this.props;
+ return children;
+ }
+}
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps,
+)(GlobalErrorBoundary);
diff --git a/cvat-ui/src/components/global-error-boundary/styles.scss b/cvat-ui/src/components/global-error-boundary/styles.scss
new file mode 100644
index 00000000000..c209a40a0cb
--- /dev/null
+++ b/cvat-ui/src/components/global-error-boundary/styles.scss
@@ -0,0 +1,17 @@
+// Copyright (C) 2020 Intel Corporation
+//
+// SPDX-License-Identifier: MIT
+
+@import '../../base.scss';
+
+.cvat-global-boundary {
+ .ant-result > .ant-result-content {
+ background-color: $transparent-color;
+ }
+
+ .cvat-global-boundary-error-field {
+ color: red;
+ }
+}
+
+
diff --git a/cvat-ui/src/containers/annotation-page/annotation-page.tsx b/cvat-ui/src/containers/annotation-page/annotation-page.tsx
index 264d1d9450f..8d2972ba66b 100644
--- a/cvat-ui/src/containers/annotation-page/annotation-page.tsx
+++ b/cvat-ui/src/containers/annotation-page/annotation-page.tsx
@@ -7,7 +7,7 @@ import { withRouter } from 'react-router-dom';
import { RouteComponentProps } from 'react-router';
import AnnotationPageComponent from 'components/annotation-page/annotation-page';
-import { getJobAsync } from 'actions/annotation-actions';
+import { getJobAsync, saveLogsAsync } from 'actions/annotation-actions';
import { CombinedState, Workspace } from 'reducers/interfaces';
@@ -24,6 +24,7 @@ interface StateToProps {
interface DispatchToProps {
getJob(): void;
+ saveLogs(): void;
}
function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps {
@@ -77,6 +78,9 @@ function mapDispatchToProps(dispatch: any, own: OwnProps): DispatchToProps {
getJob(): void {
dispatch(getJobAsync(taskID, jobID, initialFrame, initialFilters));
},
+ saveLogs(): void {
+ dispatch(saveLogsAsync());
+ },
};
}
diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx
index fb11fe1de67..4efc88037c0 100644
--- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx
+++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx
@@ -5,6 +5,8 @@
import React from 'react';
import copy from 'copy-to-clipboard';
import { connect } from 'react-redux';
+
+import { LogType } from 'cvat-logger';
import {
ActiveControl,
CombinedState,
@@ -292,13 +294,15 @@ class ObjectItemContainer extends React.PureComponent {
};
private lock = (): void => {
- const { objectState } = this.props;
+ const { objectState, jobInstance } = this.props;
+ jobInstance.logger.log(LogType.lockObject, { locked: true });
objectState.lock = true;
this.commit();
};
private unlock = (): void => {
- const { objectState } = this.props;
+ const { objectState, jobInstance } = this.props;
+ jobInstance.logger.log(LogType.lockObject, { locked: false });
objectState.lock = false;
this.commit();
};
@@ -405,7 +409,12 @@ class ObjectItemContainer extends React.PureComponent {
};
private changeAttribute = (id: number, value: string): void => {
- const { objectState } = this.props;
+ const { objectState, jobInstance } = this.props;
+ jobInstance.logger.log(LogType.changeAttribute, {
+ id,
+ value,
+ object_id: objectState.clientID,
+ });
const attr: Record = {};
attr[id] = value;
objectState.attributes = attr;
diff --git a/cvat-ui/src/cvat-logger.ts b/cvat-ui/src/cvat-logger.ts
new file mode 100644
index 00000000000..f1277ff281f
--- /dev/null
+++ b/cvat-ui/src/cvat-logger.ts
@@ -0,0 +1,14 @@
+// Copyright (C) 2020 Intel Corporation
+//
+// SPDX-License-Identifier: MIT
+
+import getCore from 'cvat-core';
+
+const core = getCore();
+const { logger } = core;
+const { LogType } = core.enums;
+
+export default logger;
+export {
+ LogType,
+};
diff --git a/cvat-ui/src/index.tsx b/cvat-ui/src/index.tsx
index 468b53722de..70ba4308b72 100644
--- a/cvat-ui/src/index.tsx
+++ b/cvat-ui/src/index.tsx
@@ -7,17 +7,18 @@ import ReactDOM from 'react-dom';
import { connect, Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
-import CVATApplication from './components/cvat-app';
+import CVATApplication from 'components/cvat-app';
-import createRootReducer from './reducers/root-reducer';
-import createCVATStore, { getCVATStore } from './cvat-store';
+import createRootReducer from 'reducers/root-reducer';
+import createCVATStore, { getCVATStore } from 'cvat-store';
+import logger, { LogType } from 'cvat-logger';
-import { authorizedAsync } from './actions/auth-actions';
-import { getFormatsAsync } from './actions/formats-actions';
-import { checkPluginsAsync } from './actions/plugins-actions';
-import { getUsersAsync } from './actions/users-actions';
-import { getAboutAsync } from './actions/about-actions';
-import { shortcutsActions } from './actions/shortcuts-actions';
+import { authorizedAsync } from 'actions/auth-actions';
+import { getFormatsAsync } from 'actions/formats-actions';
+import { checkPluginsAsync } from 'actions/plugins-actions';
+import { getUsersAsync } from 'actions/users-actions';
+import { getAboutAsync } from 'actions/about-actions';
+import { shortcutsActions } from 'actions/shortcuts-actions';
import {
resetErrors,
resetMessages,
@@ -35,6 +36,7 @@ interface StateToProps {
pluginsInitialized: boolean;
pluginsFetching: boolean;
userInitialized: boolean;
+ userFetching: boolean;
usersInitialized: boolean;
usersFetching: boolean;
aboutInitialized: boolean;
@@ -68,6 +70,7 @@ function mapStateToProps(state: CombinedState): StateToProps {
return {
userInitialized: auth.initialized,
+ userFetching: auth.fetching,
pluginsInitialized: plugins.initialized,
pluginsFetching: plugins.fetching,
usersInitialized: users.initialized,
@@ -112,3 +115,32 @@ ReactDOM.render(
),
document.getElementById('root'),
);
+
+window.onerror = (
+ message: Event | string,
+ source?: string,
+ lineno?: number,
+ colno?: number,
+ error?: Error,
+) => {
+ if (typeof (message) === 'string' && source && typeof (lineno) === 'number' && (typeof (colno) === 'number') && error) {
+ const logPayload = {
+ filename: source,
+ line: lineno,
+ message: error.message,
+ column: colno,
+ stack: error.stack,
+ };
+
+ const store = getCVATStore();
+ const state: CombinedState = store.getState();
+ const { pathname } = window.location;
+ const re = RegExp(/\/tasks\/[0-9]+\/jobs\/[0-9]+$/);
+ const { instance: job } = state.annotation.job;
+ if (re.test(pathname) && job) {
+ job.logger.log(LogType.sendException, logPayload);
+ } else {
+ logger.log(LogType.sendException, logPayload);
+ }
+ }
+};
diff --git a/cvat-ui/src/reducers/about-reducer.ts b/cvat-ui/src/reducers/about-reducer.ts
index fac7eee9ffe..efe6cff7d73 100644
--- a/cvat-ui/src/reducers/about-reducer.ts
+++ b/cvat-ui/src/reducers/about-reducer.ts
@@ -2,6 +2,7 @@
//
// SPDX-License-Identifier: MIT
+import { boundariesActions, BoundariesActionTypes } from 'actions/boundaries-actions';
import { AboutActions, AboutActionTypes } from 'actions/about-actions';
import { AuthActions, AuthActionTypes } from 'actions/auth-actions';
import { AboutState } from './interfaces';
@@ -23,7 +24,7 @@ const defaultState: AboutState = {
export default function (
state: AboutState = defaultState,
- action: AboutActions | AuthActions,
+ action: AboutActions | AuthActions | boundariesActions,
): AboutState {
switch (action.type) {
case AboutActionTypes.GET_ABOUT: {
@@ -46,7 +47,8 @@ export default function (
fetching: false,
initialized: true,
};
- case AuthActionTypes.LOGOUT_SUCCESS: {
+ case AuthActionTypes.LOGOUT_SUCCESS:
+ case BoundariesActionTypes.RESET_AFTER_ERROR: {
return {
...defaultState,
};
diff --git a/cvat-ui/src/reducers/annotation-reducer.ts b/cvat-ui/src/reducers/annotation-reducer.ts
index c44dcd848fd..3deb1af3959 100644
--- a/cvat-ui/src/reducers/annotation-reducer.ts
+++ b/cvat-ui/src/reducers/annotation-reducer.ts
@@ -7,6 +7,7 @@ import { AnyAction } from 'redux';
import { Canvas, CanvasMode } from 'cvat-canvas';
import { AnnotationActionTypes } from 'actions/annotation-actions';
import { AuthActionTypes } from 'actions/auth-actions';
+import { BoundariesActionTypes } from 'actions/boundaries-actions';
import {
AnnotationState,
ActiveControl,
@@ -104,6 +105,7 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
},
};
}
+ case BoundariesActionTypes.RESET_AFTER_ERROR:
case AnnotationActionTypes.GET_JOB_SUCCESS: {
const {
job,
@@ -153,6 +155,10 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
activeLabelID: job.task.labels[0].id,
activeObjectType: job.task.mode === 'interpolation' ? ObjectType.TRACK : ObjectType.SHAPE,
},
+ canvas: {
+ ...state.canvas,
+ instance: new Canvas(),
+ },
colors,
};
}
@@ -166,15 +172,6 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
},
};
}
- case AnnotationActionTypes.CLOSE_JOB: {
- return {
- ...defaultState,
- canvas: {
- ...defaultState.canvas,
- instance: new Canvas(),
- },
- };
- }
case AnnotationActionTypes.CHANGE_FRAME: {
return {
...state,
@@ -1065,10 +1062,9 @@ export default (state = defaultState, action: AnyAction): AnnotationState => {
},
};
}
+ case AnnotationActionTypes.CLOSE_JOB:
case AuthActionTypes.LOGOUT_SUCCESS: {
- return {
- ...defaultState,
- };
+ return { ...defaultState };
}
default: {
return state;
diff --git a/cvat-ui/src/reducers/auth-reducer.ts b/cvat-ui/src/reducers/auth-reducer.ts
index a92d6e22dcd..b679d4b1db0 100644
--- a/cvat-ui/src/reducers/auth-reducer.ts
+++ b/cvat-ui/src/reducers/auth-reducer.ts
@@ -2,6 +2,7 @@
//
// SPDX-License-Identifier: MIT
+import { boundariesActions, BoundariesActionTypes } from 'actions/boundaries-actions';
import { AuthActions, AuthActionTypes } from 'actions/auth-actions';
import { AuthState } from './interfaces';
@@ -11,7 +12,7 @@ const defaultState: AuthState = {
user: null,
};
-export default function (state = defaultState, action: AuthActions): AuthState {
+export default function (state = defaultState, action: AuthActions | boundariesActions): AuthState {
switch (action.type) {
case AuthActionTypes.AUTHORIZED_SUCCESS:
return {
@@ -68,6 +69,9 @@ export default function (state = defaultState, action: AuthActions): AuthState {
...state,
fetching: false,
};
+ case BoundariesActionTypes.RESET_AFTER_ERROR: {
+ return { ...defaultState };
+ }
default:
return state;
}
diff --git a/cvat-ui/src/reducers/formats-reducer.ts b/cvat-ui/src/reducers/formats-reducer.ts
index 3241ec9a9e3..f807b809966 100644
--- a/cvat-ui/src/reducers/formats-reducer.ts
+++ b/cvat-ui/src/reducers/formats-reducer.ts
@@ -2,6 +2,7 @@
//
// SPDX-License-Identifier: MIT
+import { boundariesActions, BoundariesActionTypes } from 'actions/boundaries-actions';
import { FormatsActionTypes, FormatsActions } from 'actions/formats-actions';
import { AuthActionTypes, AuthActions } from 'actions/auth-actions';
@@ -16,7 +17,7 @@ const defaultState: FormatsState = {
export default (
state: FormatsState = defaultState,
- action: FormatsActions | AuthActions,
+ action: FormatsActions | AuthActions | boundariesActions,
): FormatsState => {
switch (action.type) {
case FormatsActionTypes.GET_FORMATS: {
@@ -40,10 +41,9 @@ export default (
initialized: true,
fetching: false,
};
+ case BoundariesActionTypes.RESET_AFTER_ERROR:
case AuthActionTypes.LOGOUT_SUCCESS: {
- return {
- ...defaultState,
- };
+ return { ...defaultState };
}
default:
return state;
diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts
index e0595b90b9f..8dbb2720b64 100644
--- a/cvat-ui/src/reducers/interfaces.ts
+++ b/cvat-ui/src/reducers/interfaces.ts
@@ -230,6 +230,10 @@ export interface NotificationsState {
undo: null | ErrorState;
redo: null | ErrorState;
search: null | ErrorState;
+ savingLogs: null | ErrorState;
+ };
+ boundaries: {
+ resetError: null | ErrorState;
};
[index: string]: any;
diff --git a/cvat-ui/src/reducers/models-reducer.ts b/cvat-ui/src/reducers/models-reducer.ts
index b823e7b8091..cfe788b31cc 100644
--- a/cvat-ui/src/reducers/models-reducer.ts
+++ b/cvat-ui/src/reducers/models-reducer.ts
@@ -2,6 +2,7 @@
//
// SPDX-License-Identifier: MIT
+import { boundariesActions, BoundariesActionTypes } from 'actions/boundaries-actions';
import { ModelsActionTypes, ModelsActions } from 'actions/models-actions';
import { AuthActionTypes, AuthActions } from 'actions/auth-actions';
import { ModelsState } from './interfaces';
@@ -16,7 +17,10 @@ const defaultState: ModelsState = {
inferences: {},
};
-export default function (state = defaultState, action: ModelsActions | AuthActions): ModelsState {
+export default function (
+ state = defaultState,
+ action: ModelsActions | AuthActions | boundariesActions,
+): ModelsState {
switch (action.type) {
case ModelsActionTypes.GET_MODELS: {
return {
@@ -118,10 +122,9 @@ export default function (state = defaultState, action: ModelsActions | AuthActio
inferences: { ...inferences },
};
}
+ case BoundariesActionTypes.RESET_AFTER_ERROR:
case AuthActionTypes.LOGOUT_SUCCESS: {
- return {
- ...defaultState,
- };
+ return { ...defaultState };
}
default: {
return state;
diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts
index 9ca0240d680..43dd38659a2 100644
--- a/cvat-ui/src/reducers/notifications-reducer.ts
+++ b/cvat-ui/src/reducers/notifications-reducer.ts
@@ -13,6 +13,7 @@ import { UsersActionTypes } from 'actions/users-actions';
import { AboutActionTypes } from 'actions/about-actions';
import { AnnotationActionTypes } from 'actions/annotation-actions';
import { NotificationsActionType } from 'actions/notification-actions';
+import { BoundariesActionTypes } from 'actions/boundaries-actions';
import { NotificationsState } from './interfaces';
@@ -74,6 +75,10 @@ const defaultState: NotificationsState = {
undo: null,
redo: null,
search: null,
+ savingLogs: null,
+ },
+ boundaries: {
+ resetError: null,
},
},
messages: {
@@ -766,6 +771,36 @@ export default function (state = defaultState, action: AnyAction): Notifications
},
};
}
+ case AnnotationActionTypes.SAVE_LOGS_FAILED: {
+ return {
+ ...state,
+ errors: {
+ ...state.errors,
+ annotation: {
+ ...state.errors.annotation,
+ savingLogs: {
+ message: 'Could not send logs to the server',
+ reason: action.payload.error.toString(),
+ },
+ },
+ },
+ };
+ }
+ case BoundariesActionTypes.THROW_RESET_ERROR: {
+ return {
+ ...state,
+ errors: {
+ ...state.errors,
+ boundaries: {
+ ...state.errors.annotation,
+ resetError: {
+ message: 'Could not reset the state',
+ reason: action.payload.error.toString(),
+ },
+ },
+ },
+ };
+ }
case NotificationsActionType.RESET_ERRORS: {
return {
...state,
@@ -782,10 +817,9 @@ export default function (state = defaultState, action: AnyAction): Notifications
},
};
}
+ case BoundariesActionTypes.RESET_AFTER_ERROR:
case AuthActionTypes.LOGOUT_SUCCESS: {
- return {
- ...defaultState,
- };
+ return { ...defaultState };
}
default: {
return state;
diff --git a/cvat-ui/src/reducers/settings-reducer.ts b/cvat-ui/src/reducers/settings-reducer.ts
index c9f82e81d13..0a53aca4ff0 100644
--- a/cvat-ui/src/reducers/settings-reducer.ts
+++ b/cvat-ui/src/reducers/settings-reducer.ts
@@ -3,6 +3,9 @@
// SPDX-License-Identifier: MIT
import { AnyAction } from 'redux';
+
+import { BoundariesActionTypes } from 'actions/boundaries-actions';
+import { AuthActionTypes } from 'actions/auth-actions';
import { SettingsActionTypes } from 'actions/settings-actions';
import { AnnotationActionTypes } from 'actions/annotation-actions';
@@ -225,6 +228,10 @@ export default (state = defaultState, action: AnyAction): SettingsState => {
},
};
}
+ case BoundariesActionTypes.RESET_AFTER_ERROR:
+ case AuthActionTypes.LOGOUT_SUCCESS: {
+ return { ...defaultState };
+ }
default: {
return state;
}
diff --git a/cvat-ui/src/reducers/share-reducer.ts b/cvat-ui/src/reducers/share-reducer.ts
index e41fb4a2da4..702a39a7a28 100644
--- a/cvat-ui/src/reducers/share-reducer.ts
+++ b/cvat-ui/src/reducers/share-reducer.ts
@@ -2,6 +2,7 @@
//
// SPDX-License-Identifier: MIT
+import { BoundariesActionTypes, boundariesActions } from 'actions/boundaries-actions';
import { ShareActionTypes, ShareActions } from 'actions/share-actions';
import { AuthActionTypes, AuthActions } from 'actions/auth-actions';
import {
@@ -20,7 +21,7 @@ const defaultState: ShareState = {
export default function (
state: ShareState = defaultState,
- action: ShareActions | AuthActions,
+ action: ShareActions | AuthActions | boundariesActions,
): ShareState {
switch (action.type) {
case ShareActionTypes.LOAD_SHARE_DATA_SUCCESS: {
@@ -48,10 +49,9 @@ export default function (
...state,
};
}
+ case BoundariesActionTypes.RESET_AFTER_ERROR:
case AuthActionTypes.LOGOUT_SUCCESS: {
- return {
- ...defaultState,
- };
+ return { ...defaultState };
}
default:
return state;
diff --git a/cvat-ui/src/reducers/shortcuts-reducer.ts b/cvat-ui/src/reducers/shortcuts-reducer.ts
index 92397b64b01..5da35f1cd6f 100644
--- a/cvat-ui/src/reducers/shortcuts-reducer.ts
+++ b/cvat-ui/src/reducers/shortcuts-reducer.ts
@@ -1,3 +1,5 @@
+import { boundariesActions, BoundariesActionTypes } from 'actions/boundaries-actions';
+import { AuthActions, AuthActionTypes } from 'actions/auth-actions';
import { ShortcutsActions, ShortcutsActionsTypes } from 'actions/shortcuts-actions';
import { ShortcutsState } from './interfaces';
@@ -5,7 +7,10 @@ const defaultState: ShortcutsState = {
visibleShortcutsHelp: false,
};
-export default (state = defaultState, action: ShortcutsActions): ShortcutsState => {
+export default (
+ state = defaultState,
+ action: ShortcutsActions | boundariesActions | AuthActions,
+): ShortcutsState => {
switch (action.type) {
case ShortcutsActionsTypes.SWITCH_SHORTCUT_DIALOG: {
return {
@@ -13,6 +18,10 @@ export default (state = defaultState, action: ShortcutsActions): ShortcutsState
visibleShortcutsHelp: !state.visibleShortcutsHelp,
};
}
+ case BoundariesActionTypes.RESET_AFTER_ERROR:
+ case AuthActionTypes.LOGOUT_SUCCESS: {
+ return { ...defaultState };
+ }
default: {
return state;
}
diff --git a/cvat-ui/src/reducers/tasks-reducer.ts b/cvat-ui/src/reducers/tasks-reducer.ts
index a0d6160fcf5..2212b71633d 100644
--- a/cvat-ui/src/reducers/tasks-reducer.ts
+++ b/cvat-ui/src/reducers/tasks-reducer.ts
@@ -3,6 +3,7 @@
// SPDX-License-Identifier: MIT
import { AnyAction } from 'redux';
+import { BoundariesActionTypes } from 'actions/boundaries-actions';
import { TasksActionTypes } from 'actions/tasks-actions';
import { AuthActionTypes } from 'actions/auth-actions';
@@ -320,10 +321,9 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState
hideEmpty: action.payload.hideEmpty,
};
}
+ case BoundariesActionTypes.RESET_AFTER_ERROR:
case AuthActionTypes.LOGOUT_SUCCESS: {
- return {
- ...defaultState,
- };
+ return { ...defaultState };
}
default:
return state;
diff --git a/cvat-ui/src/reducers/users-reducer.ts b/cvat-ui/src/reducers/users-reducer.ts
index 064bd2c1da8..83667224812 100644
--- a/cvat-ui/src/reducers/users-reducer.ts
+++ b/cvat-ui/src/reducers/users-reducer.ts
@@ -2,6 +2,7 @@
//
// SPDX-License-Identifier: MIT
+import { BoundariesActionTypes, boundariesActions } from 'actions/boundaries-actions';
import { AuthActionTypes, AuthActions } from 'actions/auth-actions';
import { UsersActionTypes, UsersActions } from 'actions/users-actions';
import { UsersState } from './interfaces';
@@ -14,7 +15,7 @@ const defaultState: UsersState = {
export default function (
state: UsersState = defaultState,
- action: UsersActions | AuthActions,
+ action: UsersActions | AuthActions | boundariesActions,
): UsersState {
switch (action.type) {
case UsersActionTypes.GET_USERS: {
@@ -37,10 +38,9 @@ export default function (
fetching: false,
initialized: true,
};
+ case BoundariesActionTypes.RESET_AFTER_ERROR:
case AuthActionTypes.LOGOUT_SUCCESS: {
- return {
- ...defaultState,
- };
+ return { ...defaultState };
}
default:
return state;
diff --git a/cvat-ui/src/utils/redux.ts b/cvat-ui/src/utils/redux.ts
index af89f625650..507c7097ed1 100644
--- a/cvat-ui/src/utils/redux.ts
+++ b/cvat-ui/src/utils/redux.ts
@@ -3,7 +3,7 @@
// SPDX-License-Identifier: MIT
import { Action, ActionCreatorsMapObject, AnyAction } from 'redux';
-import { ThunkAction as _ThunkAction } from 'redux-thunk';
+import { ThunkAction as _ThunkAction, ThunkDispatch as _ThunkDispatch } from 'redux-thunk';
import { CombinedState } from '../reducers/interfaces';
export interface ActionWithPayload extends Action {
@@ -22,3 +22,6 @@ export type ActionUnion = ReturnType
= _ThunkAction;
+
+export type ThunkDispatch
+ = _ThunkDispatch;
diff --git a/cvat-ui/webpack.config.js b/cvat-ui/webpack.config.js
index de4c053d48c..a4d00a8e670 100644
--- a/cvat-ui/webpack.config.js
+++ b/cvat-ui/webpack.config.js
@@ -106,8 +106,8 @@ module.exports = {
},
plugins: [
new HtmlWebpackPlugin({
- template: "./src/index.html",
- inject: false,
+ template: "./src/index.html",
+ inject: false,
}),
new Dotenv({
systemvars: true,
diff --git a/cvat/apps/annotation/labelme.py b/cvat/apps/annotation/labelme.py
index 0128ca73922..baacb388ef0 100644
--- a/cvat/apps/annotation/labelme.py
+++ b/cvat/apps/annotation/labelme.py
@@ -107,17 +107,17 @@ def dump_frame_anno(frame_annotation):
return ET.tostring(root_elem, encoding='unicode', pretty_print=True)
def dump_as_labelme_annotation(file_object, annotations):
+ import os.path as osp
from zipfile import ZipFile, ZIP_DEFLATED
with ZipFile(file_object, 'w', compression=ZIP_DEFLATED) as output_zip:
for frame_annotation in annotations.group_by_frame():
xml_data = dump_frame_anno(frame_annotation)
- filename = frame_annotation.name
- filename = filename[ : filename.rfind('.')] + '.xml'
+ filename = osp.splitext(frame_annotation.name)[0] + '.xml'
output_zip.writestr(filename, xml_data)
def parse_xml_annotations(xml_data, annotations, input_zip):
- from cvat.apps.annotation.coco import mask_to_polygon
+ from datumaro.util.mask_tools import mask_to_polygons
from io import BytesIO
from lxml import etree as ET
import numpy as np
@@ -229,7 +229,7 @@ def parse_attributes(attributes_string):
mask = input_zip.read(osp.join(_MASKS_DIR, mask_file))
mask = np.asarray(Image.open(BytesIO(mask)).convert('L'))
mask = (mask != 0)
- polygons = mask_to_polygon(mask)
+ polygons = mask_to_polygons(mask)
for polygon in polygons:
ann_items.append(annotations.LabeledShape(
diff --git a/cvat/apps/auto_annotation/inference_engine.py b/cvat/apps/auto_annotation/inference_engine.py
index 310e78c45be..fb6b543d34e 100644
--- a/cvat/apps/auto_annotation/inference_engine.py
+++ b/cvat/apps/auto_annotation/inference_engine.py
@@ -2,7 +2,7 @@
#
# SPDX-License-Identifier: MIT
-from openvino.inference_engine import IENetwork, IEPlugin
+from openvino.inference_engine import IENetwork, IEPlugin, IECore, get_version
import subprocess
import os
@@ -19,7 +19,20 @@ def _check_instruction(instruction):
)
-def make_plugin():
+def make_plugin_or_core():
+ version = get_version()
+ use_core_openvino = False
+ try:
+ major, minor, reference = [int(x) for x in version.split('.')]
+ if major >= 2 and minor >= 1 and reference >= 37988:
+ use_core_openvino = True
+ except Exception:
+ pass
+
+ if use_core_openvino:
+ ie = IECore()
+ return ie
+
if _IE_PLUGINS_PATH is None:
raise OSError('Inference engine plugin path env not found in the system.')
diff --git a/cvat/apps/auto_annotation/model_loader.py b/cvat/apps/auto_annotation/model_loader.py
index cb923a9cada..e48d5c8e4d1 100644
--- a/cvat/apps/auto_annotation/model_loader.py
+++ b/cvat/apps/auto_annotation/model_loader.py
@@ -8,25 +8,22 @@
import os
import numpy as np
-from cvat.apps.auto_annotation.inference_engine import make_plugin, make_network
+from cvat.apps.auto_annotation.inference_engine import make_plugin_or_core, make_network
class ModelLoader():
def __init__(self, model, weights):
self._model = model
self._weights = weights
- IE_PLUGINS_PATH = os.getenv("IE_PLUGINS_PATH")
- if not IE_PLUGINS_PATH:
- raise OSError("Inference engine plugin path env not found in the system.")
-
- plugin = make_plugin()
+ core_or_plugin = make_plugin_or_core()
network = make_network(self._model, self._weights)
- supported_layers = plugin.get_supported_layers(network)
- not_supported_layers = [l for l in network.layers.keys() if l not in supported_layers]
- if len(not_supported_layers) != 0:
- raise Exception("Following layers are not supported by the plugin for specified device {}:\n {}".
- format(plugin.device, ", ".join(not_supported_layers)))
+ if getattr(core_or_plugin, 'get_supported_layers', False):
+ supported_layers = core_or_plugin.get_supported_layers(network)
+ not_supported_layers = [l for l in network.layers.keys() if l not in supported_layers]
+ if len(not_supported_layers) != 0:
+ raise Exception("Following layers are not supported by the plugin for specified device {}:\n {}".
+ format(core_or_plugin.device, ", ".join(not_supported_layers)))
iter_inputs = iter(network.inputs)
self._input_blob_name = next(iter_inputs)
@@ -45,7 +42,12 @@ def __init__(self, model, weights):
if self._input_blob_name in info_names:
self._input_blob_name = next(iter_inputs)
- self._net = plugin.load(network=network, num_requests=2)
+ if getattr(core_or_plugin, 'load_network', False):
+ self._net = core_or_plugin.load_network(network,
+ "CPU",
+ num_requests=2)
+ else:
+ self._net = core_or_plugin.load(network=network, num_requests=2)
input_type = network.inputs[self._input_blob_name]
self._input_layout = input_type if isinstance(input_type, list) else input_type.shape
diff --git a/cvat/apps/dataset_manager/bindings.py b/cvat/apps/dataset_manager/bindings.py
index 96d4337d8bd..5215e25fd47 100644
--- a/cvat/apps/dataset_manager/bindings.py
+++ b/cvat/apps/dataset_manager/bindings.py
@@ -82,7 +82,9 @@ def categories(self):
@staticmethod
def _load_categories(cvat_anno):
categories = {}
- label_categories = datumaro.LabelCategories()
+
+ label_categories = datumaro.LabelCategories(
+ attributes=['occluded', 'z_order'])
for _, label in cvat_anno.meta['task']['labels']:
label_categories.add(label['name'])
@@ -135,6 +137,8 @@ def convert_attrs(label, cvat_attrs):
anno_group = shape_obj.group
anno_label = map_label(shape_obj.label)
anno_attr = convert_attrs(shape_obj.label, shape_obj.attributes)
+ anno_attr['occluded'] = shape_obj.occluded
+ anno_attr['z_order'] = shape_obj.z_order
anno_points = shape_obj.points
if shape_obj.type == ShapeType.POINTS:
@@ -167,6 +171,8 @@ def __init__(self, url, db_task, user):
def match_frame(item, cvat_task_anno):
+ is_video = cvat_task_anno.meta['task']['mode'] == 'interpolation'
+
frame_number = None
if frame_number is None:
try:
@@ -183,6 +189,8 @@ def match_frame(item, cvat_task_anno):
frame_number = int(item.id)
except Exception:
pass
+ if frame_number is None and is_video and item.id.startswith('frame_'):
+ frame_number = int(item.id[len('frame_'):])
if not frame_number in cvat_task_anno.frame_info:
raise Exception("Could not match item id: '%s' with any task frame" %
item.id)
@@ -224,7 +232,7 @@ def import_dm_annotations(dm_dataset, cvat_task_anno):
frame=frame_number,
label=label_cat.items[ann.label].name,
points=ann.points,
- occluded=False,
+ occluded=ann.attributes.get('occluded') == True,
group=group_map.get(ann.group, 0),
attributes=[cvat_task_anno.Attribute(name=n, value=str(v))
for n, v in ann.attributes.items()],
diff --git a/cvat/apps/dextr_segmentation/dextr.py b/cvat/apps/dextr_segmentation/dextr.py
index 703c6d08398..628961ff576 100644
--- a/cvat/apps/dextr_segmentation/dextr.py
+++ b/cvat/apps/dextr_segmentation/dextr.py
@@ -3,7 +3,7 @@
#
# SPDX-License-Identifier: MIT
-from cvat.apps.auto_annotation.inference_engine import make_plugin, make_network
+from cvat.apps.auto_annotation.inference_engine import make_plugin_or_core, make_network
import os
import cv2
@@ -32,12 +32,15 @@ def __init__(self):
def handle(self, im_path, points):
# Lazy initialization
if not self._plugin:
- self._plugin = make_plugin()
+ self._plugin = make_plugin_or_core()
self._network = make_network(os.path.join(_DEXTR_MODEL_DIR, 'dextr.xml'),
os.path.join(_DEXTR_MODEL_DIR, 'dextr.bin'))
self._input_blob = next(iter(self._network.inputs))
self._output_blob = next(iter(self._network.outputs))
- self._exec_network = self._plugin.load(network=self._network)
+ if getattr(self._plugin, 'load_network', False):
+ self._exec_network = self._plugin.load_network(self._network, 'CPU')
+ else:
+ self._exec_network = self._plugin.load(network=self._network)
image = PIL.Image.open(im_path)
numpy_image = np.array(image)
diff --git a/cvat/apps/engine/tests/test_rest_api.py b/cvat/apps/engine/tests/test_rest_api.py
index 1578a80fea0..1c2f7a87173 100644
--- a/cvat/apps/engine/tests/test_rest_api.py
+++ b/cvat/apps/engine/tests/test_rest_api.py
@@ -2987,6 +2987,15 @@ def _get_initial_annotation(annotation_format):
"points": [20.0, 0.1, 10, 3.22, 4, 7, 10, 30, 1, 2, 4.44, 5.55],
"type": "polygon",
"occluded": True
+ },
+ {
+ "frame": 2,
+ "label_id": task["labels"][1]["id"],
+ "group": 1,
+ "attributes": [],
+ "points": [4, 7, 10, 30, 4, 5.55],
+ "type": "polygon",
+ "occluded": False
}]
tags_wo_attrs = [{
@@ -3043,6 +3052,12 @@ def _get_initial_annotation(annotation_format):
elif annotation_format == "MOT CSV 1.0":
annotations["tracks"] = rectangle_tracks_wo_attrs
+ elif annotation_format == "LabelMe ZIP 3.0 for images":
+ annotations["shapes"] = rectangle_shapes_with_attrs + \
+ rectangle_shapes_wo_attrs + \
+ polygon_shapes_wo_attrs + \
+ polygon_shapes_with_attrs
+
return annotations
response = self._get_annotation_formats(annotator)
diff --git a/cvat/apps/tf_annotation/views.py b/cvat/apps/tf_annotation/views.py
index c38820a4488..9ef2ce4db14 100644
--- a/cvat/apps/tf_annotation/views.py
+++ b/cvat/apps/tf_annotation/views.py
@@ -29,7 +29,7 @@ def load_image_into_numpy(image):
def run_inference_engine_annotation(image_list, labels_mapping, treshold):
- from cvat.apps.auto_annotation.inference_engine import make_plugin, make_network
+ from cvat.apps.auto_annotation.inference_engine import make_plugin_or_core, make_network
def _normalize_box(box, w, h, dw, dh):
xmin = min(int(box[0] * dw * w), w)
@@ -43,11 +43,14 @@ def _normalize_box(box, w, h, dw, dh):
if MODEL_PATH is None:
raise OSError('Model path env not found in the system.')
- plugin = make_plugin()
+ core_or_plugin = make_plugin_or_core()
network = make_network('{}.xml'.format(MODEL_PATH), '{}.bin'.format(MODEL_PATH))
input_blob_name = next(iter(network.inputs))
output_blob_name = next(iter(network.outputs))
- executable_network = plugin.load(network=network)
+ if getattr(core_or_plugin, 'load_network', False):
+ executable_network = core_or_plugin.load_network(network, 'CPU')
+ else:
+ executable_network = core_or_plugin.load(network=network)
job = rq.get_current_job()
del network
diff --git a/datumaro/datumaro/components/extractor.py b/datumaro/datumaro/components/extractor.py
index 247807539b9..dc7867d032d 100644
--- a/datumaro/datumaro/components/extractor.py
+++ b/datumaro/datumaro/components/extractor.py
@@ -104,6 +104,7 @@ def add(self, name, parent=None, attributes=None):
index = len(self.items)
self.items.append(self.Category(name, parent, attributes))
self._indices[name] = index
+ return index
def find(self, name):
index = self._indices.get(name)
diff --git a/datumaro/datumaro/components/project.py b/datumaro/datumaro/components/project.py
index ea184083a46..4f23639b442 100644
--- a/datumaro/datumaro/components/project.py
+++ b/datumaro/datumaro/components/project.py
@@ -234,7 +234,7 @@ def _load_plugins(cls, plugins_dir, types):
try:
exports = cls._import_module(module_dir, module_name, types,
package)
- except ImportError as e:
+ except Exception as e:
log.debug("Failed to import module '%s': %s" % (module_name, e))
continue
@@ -367,6 +367,8 @@ def categories(self):
def get(self, item_id, subset=None, path=None):
if path:
raise KeyError("Requested dataset item path is not found")
+ if subset is None:
+ subset = ''
return self._subsets[subset].items[item_id]
def put(self, item, item_id=None, subset=None, path=None):
diff --git a/datumaro/datumaro/plugins/coco_format/converter.py b/datumaro/datumaro/plugins/coco_format/converter.py
index 39fe7b15402..b5766d046c2 100644
--- a/datumaro/datumaro/plugins/coco_format/converter.py
+++ b/datumaro/datumaro/plugins/coco_format/converter.py
@@ -17,7 +17,7 @@
AnnotationType, Points
)
from datumaro.components.cli_plugin import CliPlugin
-from datumaro.util import find
+from datumaro.util import find, cast
from datumaro.util.image import save_image
import datumaro.util.mask_tools as mask_tools
import datumaro.util.annotation_tools as anno_tools
@@ -25,15 +25,6 @@
from .format import CocoTask, CocoPath
-def _cast(value, type_conv, default=None):
- if value is None:
- return default
- try:
- return type_conv(value)
- except Exception:
- return default
-
-
SegmentationMode = Enum('SegmentationMode', ['guess', 'polygons', 'mask'])
class _TaskConverter:
@@ -82,7 +73,7 @@ def save_image_info(self, item, filename):
'id': self._get_image_id(item),
'width': int(w),
'height': int(h),
- 'file_name': _cast(filename, str, ''),
+ 'file_name': cast(filename, str, ''),
'license': 0,
'flickr_url': '',
'coco_url': '',
@@ -162,8 +153,8 @@ def save_categories(self, dataset):
for idx, cat in enumerate(label_categories.items):
self.categories.append({
'id': 1 + idx,
- 'name': _cast(cat.name, str, ''),
- 'supercategory': _cast(cat.parent, str, ''),
+ 'name': cast(cat.name, str, ''),
+ 'supercategory': cast(cat.parent, str, ''),
})
@classmethod
@@ -309,7 +300,7 @@ def convert_instance(self, instance, item):
elem = {
'id': self._get_ann_id(ann),
'image_id': self._get_image_id(item),
- 'category_id': _cast(ann.label, int, -1) + 1,
+ 'category_id': cast(ann.label, int, -1) + 1,
'segmentation': segmentation,
'area': float(area),
'bbox': list(map(float, bbox)),
@@ -329,20 +320,25 @@ def save_categories(self, dataset):
label_categories = dataset.categories().get(AnnotationType.label)
if label_categories is None:
return
- points_categories = dataset.categories().get(AnnotationType.points)
- if points_categories is None:
- return
-
- for idx, kp_cat in points_categories.items.items():
- label_cat = label_categories.items[idx]
+ point_categories = dataset.categories().get(AnnotationType.points)
+ for idx, label_cat in enumerate(label_categories.items):
cat = {
'id': 1 + idx,
- 'name': _cast(label_cat.name, str, ''),
- 'supercategory': _cast(label_cat.parent, str, ''),
- 'keypoints': [str(l) for l in kp_cat.labels],
- 'skeleton': [int(i) for i in kp_cat.adjacent],
+ 'name': cast(label_cat.name, str, ''),
+ 'supercategory': cast(label_cat.parent, str, ''),
+ 'keypoints': [],
+ 'skeleton': [],
+
}
+
+ if point_categories is not None:
+ kp_cat = point_categories.items.get(idx)
+ if kp_cat is not None:
+ cat.update({
+ 'keypoints': [str(l) for l in kp_cat.labels],
+ 'skeleton': [int(i) for i in kp_cat.adjacent],
+ })
self.categories.append(cat)
def save_annotations(self, item):
@@ -412,8 +408,8 @@ def save_categories(self, dataset):
for idx, cat in enumerate(label_categories.items):
self.categories.append({
'id': 1 + idx,
- 'name': _cast(cat.name, str, ''),
- 'supercategory': _cast(cat.parent, str, ''),
+ 'name': cast(cat.name, str, ''),
+ 'supercategory': cast(cat.parent, str, ''),
})
def save_annotations(self, item):
@@ -447,14 +443,19 @@ class _Converter:
def __init__(self, extractor, save_dir,
tasks=None, save_images=False, segmentation_mode=None,
crop_covered=False):
- assert tasks is None or isinstance(tasks, (CocoTask, list))
+ assert tasks is None or isinstance(tasks, (CocoTask, list, str))
if tasks is None:
tasks = list(self._TASK_CONVERTER)
elif isinstance(tasks, CocoTask):
tasks = [tasks]
+ elif isinstance(tasks, str):
+ tasks = [CocoTask[tasks]]
else:
- for t in tasks:
- assert t in CocoTask
+ for i, t in enumerate(tasks):
+ if isinstance(t, str):
+ tasks[i] = CocoTask[t]
+ else:
+ assert t in CocoTask, t
self._tasks = tasks
self._extractor = extractor
@@ -495,7 +496,7 @@ def _make_task_converters(self):
def _get_image_id(self, item):
image_id = self._image_ids.get(item.id)
if image_id is None:
- image_id = _cast(item.id, int, len(self._image_ids) + 1)
+ image_id = cast(item.id, int, len(self._image_ids) + 1)
self._image_ids[item.id] = image_id
return image_id
@@ -546,9 +547,8 @@ def convert(self):
task_conv.save_annotations(item)
for task, task_conv in task_converters.items():
- if not task_conv.is_empty():
- task_conv.write(osp.join(self._ann_dir,
- '%s_%s.json' % (task.name, subset_name)))
+ task_conv.write(osp.join(self._ann_dir,
+ '%s_%s.json' % (task.name, subset_name)))
class CocoConverter(Converter, CliPlugin):
@staticmethod
diff --git a/datumaro/datumaro/plugins/coco_format/importer.py b/datumaro/datumaro/plugins/coco_format/importer.py
index 42e5be9fc89..932944fa6a4 100644
--- a/datumaro/datumaro/plugins/coco_format/importer.py
+++ b/datumaro/datumaro/plugins/coco_format/importer.py
@@ -11,7 +11,7 @@
from datumaro.components.extractor import Importer
from datumaro.util.log_utils import logging_disabled
-from .format import CocoTask, CocoPath
+from .format import CocoTask
class CocoImporter(Importer):
@@ -55,11 +55,8 @@ def find_subsets(path):
if path.endswith('.json') and osp.isfile(path):
subset_paths = [path]
else:
- subset_paths = glob(osp.join(path, '*_*.json'))
-
- if osp.basename(osp.normpath(path)) != CocoPath.ANNOTATIONS_DIR:
- path = osp.join(path, CocoPath.ANNOTATIONS_DIR)
- subset_paths += glob(osp.join(path, '*_*.json'))
+ subset_paths = glob(osp.join(path, '**', '*_*.json'),
+ recursive=True)
subsets = defaultdict(dict)
for subset_path in subset_paths:
diff --git a/datumaro/datumaro/plugins/cvat_format/converter.py b/datumaro/datumaro/plugins/cvat_format/converter.py
index a64addad02b..1d364184477 100644
--- a/datumaro/datumaro/plugins/cvat_format/converter.py
+++ b/datumaro/datumaro/plugins/cvat_format/converter.py
@@ -12,19 +12,12 @@
from datumaro.components.cli_plugin import CliPlugin
from datumaro.components.converter import Converter
from datumaro.components.extractor import DEFAULT_SUBSET_NAME, AnnotationType
+from datumaro.util import cast
from datumaro.util.image import save_image
from .format import CvatPath
-def _cast(value, type_conv, default=None):
- if value is None:
- return default
- try:
- return type_conv(value)
- except Exception:
- return default
-
def pairwise(iterable):
a = iter(iterable)
return zip(a, a)
@@ -188,7 +181,7 @@ def _save_image(self, item):
def _write_item(self, item, index):
image_info = OrderedDict([
- ("id", str(_cast(item.id, int, index))),
+ ("id", str(cast(item.id, int, index))),
])
if item.has_image:
size = item.image.size
diff --git a/datumaro/datumaro/plugins/cvat_format/importer.py b/datumaro/datumaro/plugins/cvat_format/importer.py
index 79be0c61052..31f8dbd44f5 100644
--- a/datumaro/datumaro/plugins/cvat_format/importer.py
+++ b/datumaro/datumaro/plugins/cvat_format/importer.py
@@ -9,8 +9,6 @@
from datumaro.components.extractor import Importer
-from .format import CvatPath
-
class CvatImporter(Importer):
EXTRACTOR_NAME = 'cvat'
@@ -49,9 +47,5 @@ def find_subsets(path):
if path.endswith('.xml') and osp.isfile(path):
subset_paths = [path]
else:
- subset_paths = glob(osp.join(path, '*.xml'))
-
- if osp.basename(osp.normpath(path)) != CvatPath.ANNOTATIONS_DIR:
- path = osp.join(path, CvatPath.ANNOTATIONS_DIR)
- subset_paths += glob(osp.join(path, '*.xml'))
+ subset_paths = glob(osp.join(path, '**', '*.xml'), recursive=True)
return subset_paths
\ No newline at end of file
diff --git a/datumaro/datumaro/plugins/datumaro_format/converter.py b/datumaro/datumaro/plugins/datumaro_format/converter.py
index cc860cbad3d..08dc0062dd2 100644
--- a/datumaro/datumaro/plugins/datumaro_format/converter.py
+++ b/datumaro/datumaro/plugins/datumaro_format/converter.py
@@ -16,6 +16,7 @@
Label, Mask, RleMask, Points, Polygon, PolyLine, Bbox, Caption,
LabelCategories, MaskCategories, PointsCategories
)
+from datumaro.util import cast
from datumaro.util.image import save_image
import pycocotools.mask as mask_utils
from datumaro.components.cli_plugin import CliPlugin
@@ -23,14 +24,6 @@
from .format import DatumaroPath
-def _cast(value, type_conv, default=None):
- if value is None:
- return default
- try:
- return type_conv(value)
- except Exception:
- return default
-
class _SubsetWriter:
def __init__(self, name, context):
self._name = name
@@ -108,10 +101,10 @@ def _convert_annotation(self, obj):
assert isinstance(obj, Annotation)
ann_json = {
- 'id': _cast(obj.id, int),
- 'type': _cast(obj.type.name, str),
+ 'id': cast(obj.id, int),
+ 'type': cast(obj.type.name, str),
'attributes': obj.attributes,
- 'group': _cast(obj.group, int, 0),
+ 'group': cast(obj.group, int, 0),
}
return ann_json
@@ -119,7 +112,7 @@ def _convert_label_object(self, obj):
converted = self._convert_annotation(obj)
converted.update({
- 'label_id': _cast(obj.label, int),
+ 'label_id': cast(obj.label, int),
})
return converted
@@ -133,7 +126,7 @@ def _convert_mask_object(self, obj):
np.require(obj.image, dtype=np.uint8, requirements='F'))
converted.update({
- 'label_id': _cast(obj.label, int),
+ 'label_id': cast(obj.label, int),
'rle': {
# serialize as compressed COCO mask
'counts': rle['counts'].decode('ascii'),
@@ -146,7 +139,7 @@ def _convert_polyline_object(self, obj):
converted = self._convert_annotation(obj)
converted.update({
- 'label_id': _cast(obj.label, int),
+ 'label_id': cast(obj.label, int),
'points': [float(p) for p in obj.points],
})
return converted
@@ -155,7 +148,7 @@ def _convert_polygon_object(self, obj):
converted = self._convert_annotation(obj)
converted.update({
- 'label_id': _cast(obj.label, int),
+ 'label_id': cast(obj.label, int),
'points': [float(p) for p in obj.points],
})
return converted
@@ -164,7 +157,7 @@ def _convert_bbox_object(self, obj):
converted = self._convert_annotation(obj)
converted.update({
- 'label_id': _cast(obj.label, int),
+ 'label_id': cast(obj.label, int),
'bbox': [float(p) for p in obj.get_bbox()],
})
return converted
@@ -173,7 +166,7 @@ def _convert_points_object(self, obj):
converted = self._convert_annotation(obj)
converted.update({
- 'label_id': _cast(obj.label, int),
+ 'label_id': cast(obj.label, int),
'points': [float(p) for p in obj.points],
'visibility': [int(v.value) for v in obj.visibility],
})
@@ -183,7 +176,7 @@ def _convert_caption_object(self, obj):
converted = self._convert_annotation(obj)
converted.update({
- 'caption': _cast(obj.caption, str),
+ 'caption': cast(obj.caption, str),
})
return converted
@@ -193,8 +186,8 @@ def _convert_label_categories(self, obj):
}
for label in obj.items:
converted['labels'].append({
- 'name': _cast(label.name, str),
- 'parent': _cast(label.parent, str),
+ 'name': cast(label.name, str),
+ 'parent': cast(label.parent, str),
})
return converted
@@ -218,7 +211,7 @@ def _convert_points_categories(self, obj):
for label_id, item in obj.items.items():
converted['items'].append({
'label_id': int(label_id),
- 'labels': [_cast(label, str) for label in item.labels],
+ 'labels': [cast(label, str) for label in item.labels],
'adjacent': [int(v) for v in item.adjacent],
})
return converted
diff --git a/datumaro/datumaro/plugins/labelme_format.py b/datumaro/datumaro/plugins/labelme_format.py
new file mode 100644
index 00000000000..41069da9dab
--- /dev/null
+++ b/datumaro/datumaro/plugins/labelme_format.py
@@ -0,0 +1,472 @@
+
+# Copyright (C) 2020 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+from collections import defaultdict
+from defusedxml import ElementTree
+import logging as log
+import numpy as np
+import os
+import os.path as osp
+
+from datumaro.components.extractor import (SourceExtractor,
+ DatasetItem, AnnotationType, Mask, Bbox, Polygon, LabelCategories
+)
+from datumaro.components.extractor import Importer
+from datumaro.components.converter import Converter
+from datumaro.components.cli_plugin import CliPlugin
+from datumaro.util.image import Image, save_image
+from datumaro.util.mask_tools import load_mask, find_mask_bbox
+
+
+class LabelMePath:
+ MASKS_DIR = 'Masks'
+ IMAGE_EXT = '.jpg'
+
+class LabelMeExtractor(SourceExtractor):
+ def __init__(self, path, subset_name=None):
+ super().__init__()
+
+ assert osp.isdir(path)
+ self._rootdir = path
+
+ self._subset = subset_name
+
+ items, categories = self._parse(path)
+ self._categories = categories
+ self._items = items
+
+ def categories(self):
+ return self._categories
+
+ def __iter__(self):
+ for item in self._items:
+ yield item
+
+ def __len__(self):
+ return len(self._items)
+
+ def subsets(self):
+ if self._subset:
+ return [self._subset]
+ return None
+
+ def get_subset(self, name):
+ if name != self._subset:
+ return None
+ return self
+
+ def _parse(self, path):
+ categories = {
+ AnnotationType.label: LabelCategories(attributes={
+ 'occluded', 'username'
+ })
+ }
+
+ items = []
+ for p in sorted(p for p in os.listdir(path) if p.endswith('.xml')):
+ root = ElementTree.parse(osp.join(path, p))
+
+ image = None
+ image_path = osp.join(path, root.find('filename').text)
+
+ image_size = None
+ imagesize_elem = root.find('imagesize')
+ if imagesize_elem is not None:
+ width_elem = imagesize_elem.find('ncols')
+ height_elem = imagesize_elem.find('nrows')
+ image_size = (int(height_elem.text), int(width_elem.text))
+ image = Image(path=image_path, size=image_size)
+
+ annotations = self._parse_annotations(root, path, categories)
+
+ items.append(DatasetItem(id=osp.splitext(p)[0], subset=self._subset,
+ image=image, annotations=annotations))
+ return items, categories
+
+ @classmethod
+ def _parse_annotations(cls, xml_root, dataset_root, categories):
+ def parse_attributes(attr_str):
+ parsed = []
+ if not attr_str:
+ return parsed
+
+ for attr in [a.strip() for a in attr_str.split(',') if a.strip()]:
+ if '=' in attr:
+ name, value = attr.split('=', maxsplit=1)
+ parsed.append((name, value))
+ else:
+ parsed.append((attr, '1'))
+
+ return parsed
+
+ label_cat = categories[AnnotationType.label]
+ def get_label_id(label):
+ if not label:
+ return None
+ idx, _ = label_cat.find(label)
+ if idx is None:
+ idx = label_cat.add(label)
+ return idx
+
+ image_annotations = []
+
+ parsed_annotations = dict()
+ group_assignments = dict()
+ root_annotations = set()
+ for obj_elem in xml_root.iter('object'):
+ obj_id = int(obj_elem.find('id').text)
+
+ ann_items = []
+
+ label = get_label_id(obj_elem.find('name').text)
+
+ attributes = []
+ attributes_elem = obj_elem.find('attributes')
+ if attributes_elem is not None and attributes_elem.text:
+ attributes = parse_attributes(attributes_elem.text)
+
+ occluded = False
+ occluded_elem = obj_elem.find('occluded')
+ if occluded_elem is not None and occluded_elem.text:
+ occluded = (occluded_elem.text == 'yes')
+ attributes.append(('occluded', occluded))
+
+ deleted = False
+ deleted_elem = obj_elem.find('deleted')
+ if deleted_elem is not None and deleted_elem.text:
+ deleted = bool(int(deleted_elem.text))
+
+ user = ''
+
+ poly_elem = obj_elem.find('polygon')
+ segm_elem = obj_elem.find('segm')
+ type_elem = obj_elem.find('type') # the only value is 'bounding_box'
+ if poly_elem is not None:
+ user_elem = poly_elem.find('username')
+ if user_elem is not None and user_elem.text:
+ user = user_elem.text
+ attributes.append(('username', user))
+
+ points = []
+ for point_elem in poly_elem.iter('pt'):
+ x = float(point_elem.find('x').text)
+ y = float(point_elem.find('y').text)
+ points.append(x)
+ points.append(y)
+
+ if type_elem is not None and type_elem.text == 'bounding_box':
+ xmin = min(points[::2])
+ xmax = max(points[::2])
+ ymin = min(points[1::2])
+ ymax = max(points[1::2])
+ ann_items.append(Bbox(xmin, ymin, xmax - xmin, ymax - ymin,
+ label=label, attributes=attributes, id=obj_id,
+ ))
+ else:
+ ann_items.append(Polygon(points,
+ label=label, attributes=attributes, id=obj_id,
+ ))
+ elif segm_elem is not None:
+ user_elem = segm_elem.find('username')
+ if user_elem is not None and user_elem.text:
+ user = user_elem.text
+ attributes.append(('username', user))
+
+ mask_path = osp.join(dataset_root, LabelMePath.MASKS_DIR,
+ segm_elem.find('mask').text)
+ if not osp.isfile(mask_path):
+ raise Exception("Can't find mask at '%s'" % mask_path)
+ mask = load_mask(mask_path)
+ mask = np.any(mask, axis=2)
+ ann_items.append(Mask(image=mask, label=label, id=obj_id,
+ attributes=attributes))
+
+ if not deleted:
+ parsed_annotations[obj_id] = ann_items
+
+ # Find parents and children
+ parts_elem = obj_elem.find('parts')
+ if parts_elem is not None:
+ children_ids = []
+ hasparts_elem = parts_elem.find('hasparts')
+ if hasparts_elem is not None and hasparts_elem.text:
+ children_ids = [int(c) for c in hasparts_elem.text.split(',')]
+
+ parent_ids = []
+ ispartof_elem = parts_elem.find('ispartof')
+ if ispartof_elem is not None and ispartof_elem.text:
+ parent_ids = [int(c) for c in ispartof_elem.text.split(',')]
+
+ if children_ids and not parent_ids and hasparts_elem.text:
+ root_annotations.add(obj_id)
+ group_assignments[obj_id] = [None, children_ids]
+
+ # assign single group to all grouped annotations
+ current_group_id = 0
+ annotations_to_visit = list(root_annotations)
+ while annotations_to_visit:
+ ann_id = annotations_to_visit.pop()
+ ann_assignment = group_assignments[ann_id]
+ group_id, children_ids = ann_assignment
+ if group_id:
+ continue
+
+ if ann_id in root_annotations:
+ current_group_id += 1 # start a new group
+
+ group_id = current_group_id
+ ann_assignment[0] = group_id
+
+ # continue with children
+ annotations_to_visit.extend(children_ids)
+
+ assert current_group_id == len(root_annotations)
+
+ for ann_id, ann_items in parsed_annotations.items():
+ group_id = 0
+ if ann_id in group_assignments:
+ ann_assignment = group_assignments[ann_id]
+ group_id = ann_assignment[0]
+
+ for ann_item in ann_items:
+ if group_id:
+ ann_item.group = group_id
+
+ image_annotations.append(ann_item)
+
+ return image_annotations
+
+
+class LabelMeImporter(Importer):
+ _EXTRACTOR_NAME = 'label_me'
+
+ @classmethod
+ def detect(cls, path):
+ if not osp.isdir(path):
+ return False
+ return len(cls.find_subsets(path)) != 0
+
+ def __call__(self, path, **extra_params):
+ from datumaro.components.project import Project # cyclic import
+ project = Project()
+
+ subset_paths = self.find_subsets(path)
+ if len(subset_paths) == 0:
+ raise Exception("Failed to find 'label_me' dataset at '%s'" % path)
+
+ for subset_path, subset_name in subset_paths:
+ params = {}
+ if subset_name:
+ params['subset_name'] = subset_name
+ params.update(extra_params)
+
+ source_name = osp.splitext(osp.basename(subset_path))[0]
+ project.add_source(source_name,
+ {
+ 'url': subset_path,
+ 'format': self._EXTRACTOR_NAME,
+ 'options': params,
+ })
+
+ return project
+
+ @staticmethod
+ def find_subsets(path):
+ subset_paths = []
+ if not osp.isdir(path):
+ raise Exception("Expected directory path, got '%s'" % path)
+
+ path = osp.normpath(path)
+
+ def has_annotations(d):
+ return len([p for p in os.listdir(d) if p.endswith('.xml')]) != 0
+
+ if has_annotations(path):
+ subset_paths = [(path, None)]
+ else:
+ for d in os.listdir(path):
+ subset = d
+ d = osp.join(path, d)
+ if osp.isdir(d) and has_annotations(d):
+ subset_paths.append((d, subset))
+ return subset_paths
+
+
+class LabelMeConverter(Converter, CliPlugin):
+ @classmethod
+ def build_cmdline_parser(cls, **kwargs):
+ parser = super().build_cmdline_parser(**kwargs)
+ parser.add_argument('--save-images', action='store_true',
+ help="Save images (default: %(default)s)")
+ return parser
+
+ def __init__(self, save_images=False):
+ super().__init__()
+
+ self._save_images = save_images
+
+ def __call__(self, extractor, save_dir):
+ self._extractor = extractor
+
+ subsets = extractor.subsets()
+ if len(subsets) == 0:
+ subsets = [ None ]
+
+ for subset_name in subsets:
+ if subset_name:
+ subset = extractor.get_subset(subset_name)
+ else:
+ subset_name = DEFAULT_SUBSET_NAME
+ subset = extractor
+
+ subset_dir = osp.join(save_dir, subset_name)
+ os.makedirs(subset_dir, exist_ok=True)
+ os.makedirs(osp.join(subset_dir, LabelMePath.MASKS_DIR),
+ exist_ok=True)
+
+ for item in subset:
+ self._save_item(item, subset_dir)
+
+ def _get_label(self, label_id):
+ if label_id is None:
+ return ''
+ return self._extractor.categories()[AnnotationType.label] \
+ .items[label_id].name
+
+ def _save_item(self, item, subset_dir):
+ from lxml import etree as ET
+
+ log.debug("Converting item '%s'", item.id)
+
+ image_filename = ''
+ if item.has_image:
+ image_filename = item.image.filename
+ if self._save_images:
+ if item.has_image and item.image.has_data:
+ if image_filename:
+ image_filename = osp.splitext(image_filename)[0]
+ else:
+ image_filename = item.id
+ image_filename += LabelMePath.IMAGE_EXT
+ save_image(osp.join(subset_dir, image_filename),
+ item.image.data)
+ else:
+ log.debug("Item '%s' has no image" % item.id)
+
+ root_elem = ET.Element('annotation')
+ ET.SubElement(root_elem, 'filename').text = image_filename
+ ET.SubElement(root_elem, 'folder').text = ''
+
+ source_elem = ET.SubElement(root_elem, 'source')
+ ET.SubElement(source_elem, 'sourceImage').text = ''
+ ET.SubElement(source_elem, 'sourceAnnotation').text = 'Datumaro'
+
+ if item.has_image:
+ image_elem = ET.SubElement(root_elem, 'imagesize')
+ image_size = item.image.size
+ ET.SubElement(image_elem, 'nrows').text = str(image_size[0])
+ ET.SubElement(image_elem, 'ncols').text = str(image_size[1])
+
+ groups = defaultdict(list)
+
+ obj_id = 0
+ for ann in item.annotations:
+ if not ann.type in { AnnotationType.polygon,
+ AnnotationType.bbox, AnnotationType.mask }:
+ continue
+
+ obj_elem = ET.SubElement(root_elem, 'object')
+ ET.SubElement(obj_elem, 'name').text = self._get_label(ann.label)
+ ET.SubElement(obj_elem, 'deleted').text = '0'
+ ET.SubElement(obj_elem, 'verified').text = '0'
+ ET.SubElement(obj_elem, 'occluded').text = \
+ 'yes' if ann.attributes.pop('occluded', '') == True else 'no'
+ ET.SubElement(obj_elem, 'date').text = ''
+ ET.SubElement(obj_elem, 'id').text = str(obj_id)
+
+ parts_elem = ET.SubElement(obj_elem, 'parts')
+ if ann.group:
+ groups[ann.group].append((obj_id, parts_elem))
+ else:
+ ET.SubElement(parts_elem, 'hasparts').text = ''
+ ET.SubElement(parts_elem, 'ispartof').text = ''
+
+ if ann.type == AnnotationType.bbox:
+ ET.SubElement(obj_elem, 'type').text = 'bounding_box'
+
+ poly_elem = ET.SubElement(obj_elem, 'polygon')
+ x0, y0, x1, y1 = ann.points
+ points = [ (x0, y0), (x1, y0), (x1, y1), (x0, y1) ]
+ for x, y in points:
+ point_elem = ET.SubElement(poly_elem, 'pt')
+ ET.SubElement(point_elem, 'x').text = '%.2f' % x
+ ET.SubElement(point_elem, 'y').text = '%.2f' % y
+
+ ET.SubElement(poly_elem, 'username').text = \
+ str(ann.attributes.pop('username', ''))
+ elif ann.type == AnnotationType.polygon:
+ poly_elem = ET.SubElement(obj_elem, 'polygon')
+ for x, y in zip(ann.points[::2], ann.points[1::2]):
+ point_elem = ET.SubElement(poly_elem, 'pt')
+ ET.SubElement(point_elem, 'x').text = '%.2f' % x
+ ET.SubElement(point_elem, 'y').text = '%.2f' % y
+
+ ET.SubElement(poly_elem, 'username').text = \
+ str(ann.attributes.pop('username', ''))
+ elif ann.type == AnnotationType.mask:
+ mask_filename = '%s_mask_%s.png' % (item.id, obj_id)
+ save_image(osp.join(subset_dir, LabelMePath.MASKS_DIR,
+ mask_filename),
+ self._paint_mask(ann.image))
+
+ segm_elem = ET.SubElement(obj_elem, 'segm')
+ ET.SubElement(segm_elem, 'mask').text = mask_filename
+
+ bbox = find_mask_bbox(ann.image)
+ box_elem = ET.SubElement(segm_elem, 'box')
+ ET.SubElement(box_elem, 'xmin').text = '%.2f' % bbox[0]
+ ET.SubElement(box_elem, 'ymin').text = '%.2f' % bbox[1]
+ ET.SubElement(box_elem, 'xmax').text = \
+ '%.2f' % (bbox[0] + bbox[2])
+ ET.SubElement(box_elem, 'ymax').text = \
+ '%.2f' % (bbox[1] + bbox[3])
+
+ ET.SubElement(segm_elem, 'username').text = \
+ str(ann.attributes.pop('username', ''))
+ else:
+ raise NotImplementedError("Unknown shape type '%s'" % ann.type)
+
+ attrs = []
+ for k, v in ann.attributes.items():
+ if isinstance(v, bool):
+ attrs.append(k)
+ else:
+ attrs.append('%s=%s' % (k, v))
+ ET.SubElement(obj_elem, 'attributes').text = ', '.join(attrs)
+
+ obj_id += 1
+
+ for _, group in groups.items():
+ leader_id, leader_parts_elem = group[0]
+ leader_parts = [str(o_id) for o_id, _ in group[1:]]
+ ET.SubElement(leader_parts_elem, 'hasparts').text = \
+ ','.join(leader_parts)
+ ET.SubElement(leader_parts_elem, 'ispartof').text = ''
+
+ for obj_id, parts_elem in group[1:]:
+ ET.SubElement(parts_elem, 'hasparts').text = ''
+ ET.SubElement(parts_elem, 'ispartof').text = str(leader_id)
+
+ xml_path = osp.join(subset_dir, '%s.xml' % item.id)
+ with open(xml_path, 'w', encoding='utf-8') as f:
+ xml_data = ET.tostring(root_elem, encoding='unicode',
+ pretty_print=True)
+ f.write(xml_data)
+
+ @staticmethod
+ def _paint_mask(mask):
+ # TODO: check if mask colors are random
+ return np.array([[0, 0, 0, 0], [255, 203, 0, 153]],
+ dtype=np.uint8)[mask.astype(np.uint8)]
\ No newline at end of file
diff --git a/datumaro/datumaro/plugins/mot_format.py b/datumaro/datumaro/plugins/mot_format.py
new file mode 100644
index 00000000000..18d3695b145
--- /dev/null
+++ b/datumaro/datumaro/plugins/mot_format.py
@@ -0,0 +1,341 @@
+
+# Copyright (C) 2020 Intel Corporation
+#
+# SPDX-License-Identifier: MIT
+
+# The Multiple Object Tracking Benchmark challenge format support
+# Format description: https://arxiv.org/pdf/1906.04567.pdf
+# Another description: https://motchallenge.net/instructions
+
+from collections import OrderedDict
+import csv
+from enum import Enum
+import logging as log
+import os
+import os.path as osp
+
+from datumaro.components.extractor import (SourceExtractor,
+ DatasetItem, AnnotationType, Bbox, LabelCategories
+)
+from datumaro.components.extractor import Importer
+from datumaro.components.converter import Converter
+from datumaro.components.cli_plugin import CliPlugin
+from datumaro.util import cast
+from datumaro.util.image import Image, save_image
+
+
+MotLabel = Enum('MotLabel', [
+ ('pedestrian', 1),
+ ('person on vehicle', 2),
+ ('car', 3),
+ ('bicycle', 4),
+ ('motorbike', 5),
+ ('non motorized vehicle', 6),
+ ('static person', 7),
+ ('distractor', 8),
+ ('occluder', 9),
+ ('occluder on the ground', 10),
+ ('occluder full', 11),
+ ('reflection', 12),
+])
+
+class MotPath:
+ IMAGE_DIR = 'img1'
+ SEQINFO_FILE = 'seqinfo.ini'
+ LABELS_FILE = 'labels.txt'
+ GT_FILENAME = 'gt.txt'
+ DET_FILENAME = 'det.txt'
+
+ IMAGE_EXT = '.jpg'
+
+ FIELDS = [
+ 'frame_id',
+ 'track_id',
+ 'x',
+ 'y',
+ 'w',
+ 'h',
+ 'confidence', # or 'not ignored' flag for GT anns
+ 'class_id',
+ 'visibility'
+ ]
+
+
+class MotSeqExtractor(SourceExtractor):
+ def __init__(self, path, labels=None, occlusion_threshold=0, is_gt=None):
+ super().__init__()
+
+ assert osp.isfile(path)
+ self._path = path
+ seq_root = osp.dirname(osp.dirname(path))
+
+ self._image_dir = ''
+ if osp.isdir(osp.join(seq_root, MotPath.IMAGE_DIR)):
+ self._image_dir = osp.join(seq_root, MotPath.IMAGE_DIR)
+
+ seq_info = osp.join(seq_root, MotPath.SEQINFO_FILE)
+ if osp.isfile(seq_info):
+ seq_info = self._parse_seq_info(seq_info)
+ self._image_dir = osp.join(seq_root, seq_info['imdir'])
+ else:
+ seq_info = None
+ self._seq_info = seq_info
+
+ self._occlusion_threshold = float(occlusion_threshold)
+
+ assert is_gt in {None, True, False}
+ if is_gt is None:
+ if osp.basename(path) == MotPath.DET_FILENAME:
+ is_gt = False
+ else:
+ is_gt = True
+ self._is_gt = is_gt
+
+ self._subset = None
+
+ if labels is None:
+ if osp.isfile(osp.join(seq_root, MotPath.LABELS_FILE)):
+ labels = osp.join(seq_root, MotPath.LABELS_FILE)
+ else:
+ labels = [lbl.name for lbl in MotLabel]
+ if isinstance(labels, str):
+ labels = self._parse_labels(labels)
+ elif isinstance(labels, list):
+ assert all(isinstance(lbl, str) for lbl in labels), labels
+ else:
+ raise TypeError("Unexpected type of 'labels' argument: %s" % labels)
+ self._categories = self._load_categories(labels)
+ self._items = self._load_items(path)
+
+ def categories(self):
+ return self._categories
+
+ def __iter__(self):
+ for item in self._items.values():
+ yield item
+
+ def __len__(self):
+ return len(self._items)
+
+ def subsets(self):
+ if self._subset:
+ return [self._subset]
+ return None
+
+ def get_subset(self, name):
+ if name != self._subset:
+ return None
+ return self
+
+ @staticmethod
+ def _parse_labels(path):
+ with open(path, encoding='utf-8') as labels_file:
+ return [s.strip() for s in labels_file]
+
+ def _load_categories(self, labels):
+ attributes = ['track_id']
+ if self._is_gt:
+ attributes += ['occluded', 'visibility', 'ignored']
+ else:
+ attributes += ['score']
+ label_cat = LabelCategories(attributes=attributes)
+ for label in labels:
+ label_cat.add(label)
+
+ return { AnnotationType.label: label_cat }
+
+ def _load_items(self, path):
+ labels_count = len(self._categories[AnnotationType.label].items)
+ items = OrderedDict()
+
+ if self._seq_info:
+ for frame_id in range(self._seq_info['seqlength']):
+ items[frame_id] = DatasetItem(
+ id=frame_id,
+ subset=self._subset,
+ image=Image(
+ path=osp.join(self._image_dir,
+ '%06d%s' % (frame_id, self._seq_info['imext'])),
+ size=(self._seq_info['imheight'], self._seq_info['imwidth'])
+ )
+ )
+ elif osp.isdir(self._image_dir):
+ for p in os.listdir(self._image_dir):
+ if p.endswith(MotPath.IMAGE_EXT):
+ frame_id = int(osp.splitext(p)[0])
+ items[frame_id] = DatasetItem(
+ id=frame_id,
+ subset=self._subset,
+ image=osp.join(self._image_dir, p),
+ )
+
+ with open(path, newline='', encoding='utf-8') as csv_file:
+ # NOTE: Different MOT files have different count of fields
+ # (7, 9 or 10). This is handled by reader:
+ # - all extra fields go to a separate field
+ # - all unmet fields have None values
+ for row in csv.DictReader(csv_file, fieldnames=MotPath.FIELDS):
+ frame_id = int(row['frame_id'])
+ item = items.get(frame_id)
+ if item is None:
+ item = DatasetItem(id=frame_id, subset=self._subset)
+ annotations = item.annotations
+
+ x, y = float(row['x']), float(row['y'])
+ w, h = float(row['w']), float(row['h'])
+ label_id = row.get('class_id')
+ if label_id and label_id != '-1':
+ label_id = int(label_id) - 1
+ assert label_id < labels_count, label_id
+ else:
+ label_id = None
+
+ attributes = {}
+
+ # Annotations for detection task are not related to any track
+ track_id = int(row['track_id'])
+ if 0 < track_id:
+ attributes['track_id'] = track_id
+
+ confidence = cast(row.get('confidence'), float, 1)
+ visibility = cast(row.get('visibility'), float, 1)
+ if self._is_gt:
+ attributes['visibility'] = visibility
+ attributes['occluded'] = \
+ visibility <= self._occlusion_threshold
+ attributes['ignored'] = confidence == 0
+ else:
+ attributes['score'] = float(confidence)
+
+ annotations.append(Bbox(x, y, w, h, label=label_id,
+ attributes=attributes))
+
+ items[frame_id] = item
+ return items
+
+ @classmethod
+ def _parse_seq_info(cls, path):
+ fields = {}
+ with open(path, encoding='utf-8') as f:
+ for line in f:
+ entry = line.lower().strip().split('=', maxsplit=1)
+ if len(entry) == 2:
+ fields[entry[0]] = entry[1]
+ cls._check_seq_info(fields)
+ for k in { 'framerate', 'seqlength', 'imwidth', 'imheight' }:
+ fields[k] = int(fields[k])
+ return fields
+
+ @staticmethod
+ def _check_seq_info(seq_info):
+ assert set(seq_info) == {'name', 'imdir', 'framerate', 'seqlength', 'imwidth', 'imheight', 'imext'}, seq_info
+
+class MotSeqImporter(Importer):
+ _EXTRACTOR_NAME = 'mot_seq'
+
+ @classmethod
+ def detect(cls, path):
+ return len(cls.find_subsets(path)) != 0
+
+ def __call__(self, path, **extra_params):
+ from datumaro.components.project import Project # cyclic import
+ project = Project()
+
+ subsets = self.find_subsets(path)
+ if len(subsets) == 0:
+ raise Exception("Failed to find 'mot' dataset at '%s'" % path)
+
+ for ann_file in subsets:
+ log.info("Found a dataset at '%s'" % ann_file)
+
+ source_name = osp.splitext(osp.basename(ann_file))[0]
+ project.add_source(source_name, {
+ 'url': ann_file,
+ 'format': self._EXTRACTOR_NAME,
+ 'options': extra_params,
+ })
+
+ return project
+
+ @staticmethod
+ def find_subsets(path):
+ subsets = []
+ if path.endswith('.txt') and osp.isfile(path):
+ subsets = [path]
+ elif osp.isdir(path):
+ p = osp.join(path, 'gt', MotPath.GT_FILENAME)
+ if osp.isfile(p):
+ subsets.append(p)
+ return subsets
+
+class MotSeqGtConverter(Converter, CliPlugin):
+ @classmethod
+ def build_cmdline_parser(cls, **kwargs):
+ parser = super().__init__(**kwargs)
+ parser.add_argument('--save-images', action='store_true',
+ help="Save images (default: %(default)s)")
+ return parser
+
+ def __init__(self, save_images=False):
+ super().__init__()
+
+ self._save_images = save_images
+
+ def __call__(self, extractor, save_dir):
+ images_dir = osp.join(save_dir, MotPath.IMAGE_DIR)
+ os.makedirs(images_dir, exist_ok=True)
+ self._images_dir = images_dir
+
+ anno_dir = osp.join(save_dir, 'gt')
+ os.makedirs(anno_dir, exist_ok=True)
+ anno_file = osp.join(anno_dir, MotPath.GT_FILENAME)
+ with open(anno_file, 'w', encoding="utf-8") as csv_file:
+ writer = csv.DictWriter(csv_file, fieldnames=MotPath.FIELDS)
+ for idx, item in enumerate(extractor):
+ log.debug("Converting item '%s'", item.id)
+
+ frame_id = cast(item.id, int, 1 + idx)
+
+ for anno in item.annotations:
+ if anno.type != AnnotationType.bbox:
+ continue
+
+ writer.writerow({
+ 'frame_id': frame_id,
+ 'track_id': int(anno.attributes.get('track_id', -1)),
+ 'x': anno.x,
+ 'y': anno.y,
+ 'w': anno.w,
+ 'h': anno.h,
+ 'confidence': int(anno.attributes.get('ignored') != True),
+ 'class_id': 1 + cast(anno.label, int, -2),
+ 'visibility': float(
+ anno.attributes.get('visibility',
+ 1 - float(
+ anno.attributes.get('occluded', False)
+ )
+ )
+ )
+ })
+
+ if self._save_images:
+ if item.has_image and item.image.has_data:
+ self._save_image(item, index=frame_id)
+ else:
+ log.debug("Item '%s' has no image" % item.id)
+
+ labels_file = osp.join(save_dir, MotPath.LABELS_FILE)
+ with open(labels_file, 'w', encoding='utf-8') as f:
+ f.write('\n'.join(l.name
+ for l in extractor.categories()[AnnotationType.label].items)
+ )
+
+ def _save_image(self, item, index):
+ if item.image.filename:
+ frame_id = osp.splitext(item.image.filename)[0]
+ else:
+ frame_id = item.id
+ frame_id = cast(frame_id, int, index)
+ image_filename = '%06d%s' % (frame_id, MotPath.IMAGE_EXT)
+ save_image(osp.join(self._images_dir, image_filename),
+ item.image.data)
\ No newline at end of file
diff --git a/datumaro/datumaro/plugins/tf_detection_api_format/importer.py b/datumaro/datumaro/plugins/tf_detection_api_format/importer.py
index 9783a23cb15..169618ba2db 100644
--- a/datumaro/datumaro/plugins/tf_detection_api_format/importer.py
+++ b/datumaro/datumaro/plugins/tf_detection_api_format/importer.py
@@ -47,5 +47,6 @@ def find_subsets(path):
if path.endswith('.tfrecord') and osp.isfile(path):
subset_paths = [path]
else:
- subset_paths = glob(osp.join(path, '*.tfrecord'))
+ subset_paths = glob(osp.join(path, '**', '*.tfrecord'),
+ recursive=True)
return subset_paths
\ No newline at end of file
diff --git a/datumaro/datumaro/plugins/transforms.py b/datumaro/datumaro/plugins/transforms.py
index 78d9ecf36aa..17f0b2a08fd 100644
--- a/datumaro/datumaro/plugins/transforms.py
+++ b/datumaro/datumaro/plugins/transforms.py
@@ -443,9 +443,8 @@ def _make_label_id_map(self, src_label_cat, label_mapping, default_action):
dst_index = dst_label_cat.find(dst_label)[0]
if dst_index is None:
- dst_label_cat.add(dst_label,
+ dst_index = dst_label_cat.add(dst_label,
src_label.parent, src_label.attributes)
- dst_index = dst_label_cat.find(dst_label)[0]
id_mapping[src_index] = dst_index
if log.getLogger().isEnabledFor(log.DEBUG):
diff --git a/datumaro/datumaro/plugins/yolo_format/extractor.py b/datumaro/datumaro/plugins/yolo_format/extractor.py
index 7840b26c5ca..11e829d4a5b 100644
--- a/datumaro/datumaro/plugins/yolo_format/extractor.py
+++ b/datumaro/datumaro/plugins/yolo_format/extractor.py
@@ -90,7 +90,9 @@ def __init__(self, config_path, image_info=None):
subset = YoloExtractor.Subset(subset_name, self)
with open(list_path, 'r') as f:
subset.items = OrderedDict(
- (osp.splitext(osp.basename(p))[0], p.strip()) for p in f)
+ (osp.splitext(osp.basename(p.strip()))[0], p.strip())
+ for p in f
+ )
for item_id, image_path in subset.items.items():
image_path = self._make_local_path(image_path)
diff --git a/datumaro/datumaro/plugins/yolo_format/importer.py b/datumaro/datumaro/plugins/yolo_format/importer.py
index 4e14d0315ae..344475c6771 100644
--- a/datumaro/datumaro/plugins/yolo_format/importer.py
+++ b/datumaro/datumaro/plugins/yolo_format/importer.py
@@ -42,5 +42,5 @@ def find_configs(path):
if path.endswith('.data') and osp.isfile(path):
config_paths = [path]
else:
- config_paths = glob(osp.join(path, '*.data'))
+ config_paths = glob(osp.join(path, '**', '*.data'), recursive=True)
return config_paths
\ No newline at end of file
diff --git a/datumaro/datumaro/util/__init__.py b/datumaro/datumaro/util/__init__.py
index 87c800fe515..7c36fe8efad 100644
--- a/datumaro/datumaro/util/__init__.py
+++ b/datumaro/datumaro/util/__init__.py
@@ -4,6 +4,7 @@
# SPDX-License-Identifier: MIT
import os
+import os.path as osp
def find(iterable, pred=lambda x: True, default=None):
@@ -17,4 +18,28 @@ def dir_items(path, ext, truncate_ext=False):
if truncate_ext:
f = f[:ext_pos]
items.append(f)
- return items
\ No newline at end of file
+ return items
+
+def split_path(path):
+ path = osp.normpath(path)
+ parts = []
+
+ while True:
+ path, part = osp.split(path)
+ if part:
+ parts.append(part)
+ else:
+ if path:
+ parts.append(path)
+ break
+ parts.reverse()
+
+ return parts
+
+def cast(value, type_conv, default=None):
+ if value is None:
+ return default
+ try:
+ return type_conv(value)
+ except Exception:
+ return default
\ No newline at end of file
diff --git a/datumaro/datumaro/util/mask_tools.py b/datumaro/datumaro/util/mask_tools.py
index dea22c8ea5f..a4eb81507b0 100644
--- a/datumaro/datumaro/util/mask_tools.py
+++ b/datumaro/datumaro/util/mask_tools.py
@@ -3,7 +3,6 @@
#
# SPDX-License-Identifier: MIT
-from itertools import groupby
import numpy as np
from datumaro.util.image import lazy_image, load_image
diff --git a/datumaro/tests/assets/labelme_dataset/Masks/img1_mask_1.png b/datumaro/tests/assets/labelme_dataset/Masks/img1_mask_1.png
new file mode 100644
index 00000000000..a37c5508f9b
Binary files /dev/null and b/datumaro/tests/assets/labelme_dataset/Masks/img1_mask_1.png differ
diff --git a/datumaro/tests/assets/labelme_dataset/Masks/img1_mask_5.png b/datumaro/tests/assets/labelme_dataset/Masks/img1_mask_5.png
new file mode 100644
index 00000000000..c20e4871ae4
Binary files /dev/null and b/datumaro/tests/assets/labelme_dataset/Masks/img1_mask_5.png differ
diff --git a/datumaro/tests/assets/labelme_dataset/Scribbles/img1_scribble_1.png b/datumaro/tests/assets/labelme_dataset/Scribbles/img1_scribble_1.png
new file mode 100644
index 00000000000..6a582819f32
Binary files /dev/null and b/datumaro/tests/assets/labelme_dataset/Scribbles/img1_scribble_1.png differ
diff --git a/datumaro/tests/assets/labelme_dataset/Scribbles/img1_scribble_5.png b/datumaro/tests/assets/labelme_dataset/Scribbles/img1_scribble_5.png
new file mode 100644
index 00000000000..415e1f88b2c
Binary files /dev/null and b/datumaro/tests/assets/labelme_dataset/Scribbles/img1_scribble_5.png differ
diff --git a/datumaro/tests/assets/labelme_dataset/img1.png b/datumaro/tests/assets/labelme_dataset/img1.png
new file mode 100644
index 00000000000..26f7b564ab9
Binary files /dev/null and b/datumaro/tests/assets/labelme_dataset/img1.png differ
diff --git a/datumaro/tests/assets/labelme_dataset/img1.xml b/datumaro/tests/assets/labelme_dataset/img1.xml
new file mode 100644
index 00000000000..ff8ae1b46e3
--- /dev/null
+++ b/datumaro/tests/assets/labelme_dataset/img1.xml
@@ -0,0 +1 @@
+img1.png example_folder The MIT-CSAIL database of objects and scenes LabelMe Webtool window 0 0 25-May-2012 00:09:48 0 admin 43 34 45 34 45 37 43 37 77 102 license plate 0 0 no 27-Jul-2014 02:58:50 1 brussell 58 66 62 68 img1_mask_1.png 58 66 62 68 img1_scribble_1.png o1 0 0 yes a1 3,4 15-Nov-2019 14:38:51 2 anonymous 30 12 42 21 24 26 15 22 18 14 22 12 27 12 q1 0 0 no kj 2 15-Nov-2019 14:39:00 3 anonymous 35 21 43 22 40 28 28 31 31 22 32 25 b1 0 0 yes hg 2 15-Nov-2019 14:39:09 4 bounding_box anonymous 13 19 23 19 23 30 13 30 m1 0 0 no d 6 15-Nov-2019 14:39:30 5 bounding_box anonymous 56 14 70 23 img1_mask_5.png 55 13 70 23 img1_scribble_5.png hg 0 0 no gfd lkj lkj hi 5 15-Nov-2019 14:41:57 6 anonymous 64 21 74 24 72 32 62 34 60 27 62 22
\ No newline at end of file
diff --git a/datumaro/tests/test_coco_format.py b/datumaro/tests/test_coco_format.py
index 724fdc5a4af..f9340b659e8 100644
--- a/datumaro/tests/test_coco_format.py
+++ b/datumaro/tests/test_coco_format.py
@@ -632,10 +632,13 @@ def __iter__(self):
def categories(self):
label_cat = LabelCategories()
+ point_cat = PointsCategories()
for label in range(10):
label_cat.add('label_' + str(label))
+ point_cat.add(label)
return {
AnnotationType.label: label_cat,
+ AnnotationType.points: point_cat,
}
with TestDir() as test_dir:
@@ -651,4 +654,4 @@ def __iter__(self):
with TestDir() as test_dir:
self._test_save_and_load(TestExtractor(),
- CocoConverter(), test_dir)
\ No newline at end of file
+ CocoConverter(tasks='image_info'), test_dir)
\ No newline at end of file
diff --git a/datumaro/tests/test_labelme_format.py b/datumaro/tests/test_labelme_format.py
new file mode 100644
index 00000000000..35fa2ca848b
--- /dev/null
+++ b/datumaro/tests/test_labelme_format.py
@@ -0,0 +1,214 @@
+import numpy as np
+import os.path as osp
+
+from unittest import TestCase
+
+from datumaro.components.extractor import (Extractor, DatasetItem,
+ AnnotationType, Bbox, Mask, Polygon, LabelCategories
+)
+from datumaro.components.project import Dataset
+from datumaro.plugins.labelme_format import LabelMeExtractor, LabelMeImporter, \
+ LabelMeConverter
+from datumaro.util.test_utils import TestDir, compare_datasets
+
+
+class LabelMeConverterTest(TestCase):
+ def _test_save_and_load(self, source_dataset, converter, test_dir,
+ target_dataset=None, importer_args=None):
+ converter(source_dataset, test_dir)
+
+ if importer_args is None:
+ importer_args = {}
+ parsed_dataset = LabelMeImporter()(test_dir, **importer_args) \
+ .make_dataset()
+
+ if target_dataset is None:
+ target_dataset = source_dataset
+
+ compare_datasets(self, expected=target_dataset, actual=parsed_dataset)
+
+ def test_can_save_and_load(self):
+ class SrcExtractor(Extractor):
+ def __iter__(self):
+ return iter([
+ DatasetItem(id=1, subset='train',
+ image=np.ones((16, 16, 3)),
+ annotations=[
+ Bbox(0, 4, 4, 8, label=2, group=2),
+ Polygon([0, 4, 4, 4, 5, 6], label=3, attributes={
+ 'occluded': True
+ }),
+ Mask(np.array([[0, 1], [1, 0], [1, 1]]), group=2,
+ attributes={ 'username': 'test' }),
+ Bbox(1, 2, 3, 4, group=3),
+ Mask(np.array([[0, 0], [0, 0], [1, 1]]), group=3,
+ attributes={ 'occluded': True }
+ ),
+ ]
+ ),
+ ])
+
+ def categories(self):
+ label_cat = LabelCategories()
+ for label in range(10):
+ label_cat.add('label_' + str(label))
+ return {
+ AnnotationType.label: label_cat,
+ }
+
+ class DstExtractor(Extractor):
+ def __iter__(self):
+ return iter([
+ DatasetItem(id=1, subset='train',
+ image=np.ones((16, 16, 3)),
+ annotations=[
+ Bbox(0, 4, 4, 8, label=0, group=2, id=0,
+ attributes={
+ 'occluded': False, 'username': '',
+ }
+ ),
+ Polygon([0, 4, 4, 4, 5, 6], label=1, id=1,
+ attributes={
+ 'occluded': True, 'username': '',
+ }
+ ),
+ Mask(np.array([[0, 1], [1, 0], [1, 1]]), group=2,
+ id=2, attributes={
+ 'occluded': False, 'username': 'test'
+ }
+ ),
+ Bbox(1, 2, 3, 4, group=1, id=3, attributes={
+ 'occluded': False, 'username': '',
+ }),
+ Mask(np.array([[0, 0], [0, 0], [1, 1]]), group=1,
+ id=4, attributes={
+ 'occluded': True, 'username': ''
+ }
+ ),
+ ]
+ ),
+ ])
+
+ def categories(self):
+ label_cat = LabelCategories()
+ label_cat.add('label_2')
+ label_cat.add('label_3')
+ return {
+ AnnotationType.label: label_cat,
+ }
+
+ with TestDir() as test_dir:
+ self._test_save_and_load(
+ SrcExtractor(), LabelMeConverter(save_images=True),
+ test_dir, target_dataset=DstExtractor())
+
+
+DUMMY_DATASET_DIR = osp.join(osp.dirname(__file__), 'assets', 'labelme_dataset')
+
+class LabelMeExtractorTest(TestCase):
+ def test_can_load(self):
+ class DstExtractor(Extractor):
+ def __iter__(self):
+ img1 = np.ones((77, 102, 3)) * 255
+ img1[6:32, 7:41] = 0
+
+ mask1 = np.zeros((77, 102), dtype=int)
+ mask1[67:69, 58:63] = 1
+
+ mask2 = np.zeros((77, 102), dtype=int)
+ mask2[13:25, 54:71] = [
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
+ [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0],
+ [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
+ [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0],
+ [0, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 0, 0],
+ [0, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 0, 0, 0],
+ [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0],
+ [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0],
+ [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
+ [0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0],
+ [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
+ ]
+
+ return iter([
+ DatasetItem(id='img1', image=img1,
+ annotations=[
+ Polygon([43, 34, 45, 34, 45, 37, 43, 37],
+ label=0, id=0,
+ attributes={
+ 'occluded': False,
+ 'username': 'admin'
+ }
+ ),
+ Mask(mask1, label=1, id=1,
+ attributes={
+ 'occluded': False,
+ 'username': 'brussell'
+ }
+ ),
+ Polygon([30, 12, 42, 21, 24, 26, 15, 22, 18, 14, 22, 12, 27, 12],
+ label=2, group=2, id=2,
+ attributes={
+ 'a1': '1',
+ 'occluded': True,
+ 'username': 'anonymous'
+ }
+ ),
+ Polygon([35, 21, 43, 22, 40, 28, 28, 31, 31, 22, 32, 25],
+ label=3, group=2, id=3,
+ attributes={
+ 'kj': '1',
+ 'occluded': False,
+ 'username': 'anonymous'
+ }
+ ),
+ Bbox(13, 19, 10, 11, label=4, group=2, id=4,
+ attributes={
+ 'hg': '1',
+ 'occluded': True,
+ 'username': 'anonymous'
+ }
+ ),
+ Mask(mask2, label=5, group=1, id=5,
+ attributes={
+ 'd': '1',
+ 'occluded': False,
+ 'username': 'anonymous'
+ }
+ ),
+ Polygon([64, 21, 74, 24, 72, 32, 62, 34, 60, 27, 62, 22],
+ label=6, group=1, id=6,
+ attributes={
+ 'gfd lkj lkj hi': '1',
+ 'occluded': False,
+ 'username': 'anonymous'
+ }
+ ),
+ ]
+ ),
+ ])
+
+ def categories(self):
+ label_cat = LabelCategories()
+ label_cat.add('window')
+ label_cat.add('license plate')
+ label_cat.add('o1')
+ label_cat.add('q1')
+ label_cat.add('b1')
+ label_cat.add('m1')
+ label_cat.add('hg')
+ return {
+ AnnotationType.label: label_cat,
+ }
+
+ parsed = Dataset.from_extractors(LabelMeExtractor(DUMMY_DATASET_DIR))
+ compare_datasets(self, expected=DstExtractor(), actual=parsed)
+
+class LabelMeImporterTest(TestCase):
+ def test_can_detect(self):
+ self.assertTrue(LabelMeImporter.detect(DUMMY_DATASET_DIR))
+
+ def test_can_import(self):
+ parsed = LabelMeImporter()(DUMMY_DATASET_DIR).make_dataset()
+ self.assertEqual(1, len(parsed))
diff --git a/datumaro/tests/test_mot_format.py b/datumaro/tests/test_mot_format.py
new file mode 100644
index 00000000000..efe62502572
--- /dev/null
+++ b/datumaro/tests/test_mot_format.py
@@ -0,0 +1,146 @@
+import numpy as np
+
+from unittest import TestCase
+
+from datumaro.components.extractor import (Extractor, DatasetItem,
+ AnnotationType, Bbox, LabelCategories
+)
+from datumaro.plugins.mot_format import MotSeqGtConverter, MotSeqImporter
+from datumaro.util.test_utils import TestDir, compare_datasets
+
+
+class MotConverterTest(TestCase):
+ def _test_save_and_load(self, source_dataset, converter, test_dir,
+ target_dataset=None, importer_args=None):
+ converter(source_dataset, test_dir)
+
+ if importer_args is None:
+ importer_args = {}
+ parsed_dataset = MotSeqImporter()(test_dir, **importer_args) \
+ .make_dataset()
+
+ if target_dataset is None:
+ target_dataset = source_dataset
+
+ compare_datasets(self, expected=target_dataset, actual=parsed_dataset)
+
+ def test_can_save_bboxes(self):
+ class SrcExtractor(Extractor):
+ def __iter__(self):
+ return iter([
+ DatasetItem(id=1, subset='train',
+ image=np.ones((16, 16, 3)),
+ annotations=[
+ Bbox(0, 4, 4, 8, label=2, attributes={
+ 'occluded': True,
+ }),
+ Bbox(0, 4, 4, 4, label=3, attributes={
+ 'visibility': 0.4,
+ }),
+ Bbox(2, 4, 4, 4, attributes={
+ 'ignored': True
+ }),
+ ]
+ ),
+
+ DatasetItem(id=2, subset='val',
+ image=np.ones((8, 8, 3)),
+ annotations=[
+ Bbox(1, 2, 4, 2, label=3),
+ ]
+ ),
+
+ DatasetItem(id=3, subset='test',
+ image=np.ones((5, 4, 3)) * 3,
+ ),
+ ])
+
+ def categories(self):
+ label_cat = LabelCategories()
+ for label in range(10):
+ label_cat.add('label_' + str(label))
+ return {
+ AnnotationType.label: label_cat,
+ }
+
+ class DstExtractor(Extractor):
+ def __iter__(self):
+ return iter([
+ DatasetItem(id=1,
+ image=np.ones((16, 16, 3)),
+ annotations=[
+ Bbox(0, 4, 4, 8, label=2, attributes={
+ 'occluded': True,
+ 'visibility': 0.0,
+ 'ignored': False,
+ }),
+ Bbox(0, 4, 4, 4, label=3, attributes={
+ 'occluded': False,
+ 'visibility': 0.4,
+ 'ignored': False,
+ }),
+ Bbox(2, 4, 4, 4, attributes={
+ 'occluded': False,
+ 'visibility': 1.0,
+ 'ignored': True,
+ }),
+ ]
+ ),
+
+ DatasetItem(id=2,
+ image=np.ones((8, 8, 3)),
+ annotations=[
+ Bbox(1, 2, 4, 2, label=3, attributes={
+ 'occluded': False,
+ 'visibility': 1.0,
+ 'ignored': False,
+ }),
+ ]
+ ),
+
+ DatasetItem(id=3,
+ image=np.ones((5, 4, 3)) * 3,
+ ),
+ ])
+
+ def categories(self):
+ label_cat = LabelCategories()
+ for label in range(10):
+ label_cat.add('label_' + str(label))
+ return {
+ AnnotationType.label: label_cat,
+ }
+
+ with TestDir() as test_dir:
+ self._test_save_and_load(
+ SrcExtractor(), MotSeqGtConverter(save_images=True),
+ test_dir, target_dataset=DstExtractor())
+
+class MotImporterTest(TestCase):
+ def test_can_detect(self):
+ class TestExtractor(Extractor):
+ def __iter__(self):
+ return iter([
+ DatasetItem(id=1, subset='train',
+ image=np.ones((16, 16, 3)),
+ annotations=[
+ Bbox(0, 4, 4, 8, label=2),
+ ]
+ ),
+ ])
+
+ def categories(self):
+ label_cat = LabelCategories()
+ for label in range(10):
+ label_cat.add('label_' + str(label))
+ return {
+ AnnotationType.label: label_cat,
+ }
+
+ def generate_dummy_dataset(path):
+ MotSeqGtConverter()(TestExtractor(), save_dir=path)
+
+ with TestDir() as test_dir:
+ generate_dummy_dataset(test_dir)
+
+ self.assertTrue(MotSeqImporter.detect(test_dir))