Merge branch 'develop' into upstream/do/cypress_test_case_22_tag_annotation_mode
commit
4167d39a63
@ -0,0 +1,133 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import * as SVG from 'svg.js';
|
||||
|
||||
import consts from './consts';
|
||||
import { translateToSVG } from './shared';
|
||||
import { Geometry } from './canvasModel';
|
||||
|
||||
export interface RegionSelector {
|
||||
select(enabled: boolean): void;
|
||||
cancel(): void;
|
||||
transform(geometry: Geometry): void;
|
||||
}
|
||||
|
||||
export class RegionSelectorImpl implements RegionSelector {
|
||||
private onRegionSelected: (points?: number[]) => void;
|
||||
private geometry: Geometry;
|
||||
private canvas: SVG.Container;
|
||||
private selectionRect: SVG.Rect | null;
|
||||
private startSelectionPoint: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
private getSelectionBox(event: MouseEvent): { xtl: number; ytl: number; xbr: number; ybr: number } {
|
||||
const point = translateToSVG((this.canvas.node as any) as SVGSVGElement, [event.clientX, event.clientY]);
|
||||
const stopSelectionPoint = {
|
||||
x: point[0],
|
||||
y: point[1],
|
||||
};
|
||||
|
||||
return {
|
||||
xtl: Math.min(this.startSelectionPoint.x, stopSelectionPoint.x),
|
||||
ytl: Math.min(this.startSelectionPoint.y, stopSelectionPoint.y),
|
||||
xbr: Math.max(this.startSelectionPoint.x, stopSelectionPoint.x),
|
||||
ybr: Math.max(this.startSelectionPoint.y, stopSelectionPoint.y),
|
||||
};
|
||||
}
|
||||
|
||||
private onMouseMove = (event: MouseEvent): void => {
|
||||
if (this.selectionRect) {
|
||||
const box = this.getSelectionBox(event);
|
||||
|
||||
this.selectionRect.attr({
|
||||
x: box.xtl,
|
||||
y: box.ytl,
|
||||
width: box.xbr - box.xtl,
|
||||
height: box.ybr - box.ytl,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onMouseDown = (event: MouseEvent): void => {
|
||||
if (!this.selectionRect && !event.altKey) {
|
||||
const point = translateToSVG((this.canvas.node as any) as SVGSVGElement, [event.clientX, event.clientY]);
|
||||
this.startSelectionPoint = {
|
||||
x: point[0],
|
||||
y: point[1],
|
||||
};
|
||||
|
||||
this.selectionRect = this.canvas
|
||||
.rect()
|
||||
.attr({
|
||||
'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale,
|
||||
})
|
||||
.addClass('cvat_canvas_shape_region_selection');
|
||||
this.selectionRect.attr({ ...this.startSelectionPoint });
|
||||
}
|
||||
};
|
||||
|
||||
private onMouseUp = (): void => {
|
||||
const { offset } = this.geometry;
|
||||
if (this.selectionRect) {
|
||||
const {
|
||||
w, h, x, y, x2, y2,
|
||||
} = this.selectionRect.bbox();
|
||||
this.selectionRect.remove();
|
||||
this.selectionRect = null;
|
||||
if (w === 0 && h === 0) {
|
||||
this.onRegionSelected([x - offset, y - offset]);
|
||||
} else {
|
||||
this.onRegionSelected([x - offset, y - offset, x2 - offset, y2 - offset]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private startSelection(): void {
|
||||
this.canvas.node.addEventListener('mousemove', this.onMouseMove);
|
||||
this.canvas.node.addEventListener('mousedown', this.onMouseDown);
|
||||
this.canvas.node.addEventListener('mouseup', this.onMouseUp);
|
||||
}
|
||||
|
||||
private stopSelection(): void {
|
||||
this.canvas.node.removeEventListener('mousemove', this.onMouseMove);
|
||||
this.canvas.node.removeEventListener('mousedown', this.onMouseDown);
|
||||
this.canvas.node.removeEventListener('mouseup', this.onMouseUp);
|
||||
}
|
||||
|
||||
private release(): void {
|
||||
this.stopSelection();
|
||||
}
|
||||
|
||||
public constructor(onRegionSelected: (points?: number[]) => void, canvas: SVG.Container, geometry: Geometry) {
|
||||
this.onRegionSelected = onRegionSelected;
|
||||
this.geometry = geometry;
|
||||
this.canvas = canvas;
|
||||
this.selectionRect = null;
|
||||
}
|
||||
|
||||
public select(enabled: boolean): void {
|
||||
if (enabled) {
|
||||
this.startSelection();
|
||||
} else {
|
||||
this.release();
|
||||
}
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this.release();
|
||||
this.onRegionSelected();
|
||||
}
|
||||
|
||||
public transform(geometry: Geometry): void {
|
||||
this.geometry = geometry;
|
||||
if (this.selectionRect) {
|
||||
this.selectionRect.attr({
|
||||
'stroke-width': consts.BASE_STROKE_WIDTH / geometry.scale,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,153 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const User = require('./user');
|
||||
const { ArgumentError } = require('./exceptions');
|
||||
const { negativeIDGenerator } = require('./common');
|
||||
|
||||
/**
|
||||
* Class representing a single comment
|
||||
* @memberof module:API.cvat.classes
|
||||
* @hideconstructor
|
||||
*/
|
||||
class Comment {
|
||||
constructor(initialData) {
|
||||
const data = {
|
||||
id: undefined,
|
||||
message: undefined,
|
||||
created_date: undefined,
|
||||
updated_date: undefined,
|
||||
removed: false,
|
||||
author: undefined,
|
||||
};
|
||||
|
||||
for (const property in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) {
|
||||
data[property] = initialData[property];
|
||||
}
|
||||
}
|
||||
|
||||
if (data.author && !(data.author instanceof User)) data.author = new User(data.author);
|
||||
|
||||
if (typeof id === 'undefined') {
|
||||
data.id = negativeIDGenerator();
|
||||
}
|
||||
if (typeof data.created_date === 'undefined') {
|
||||
data.created_date = new Date().toISOString();
|
||||
}
|
||||
|
||||
Object.defineProperties(
|
||||
this,
|
||||
Object.freeze({
|
||||
/**
|
||||
* @name id
|
||||
* @type {integer}
|
||||
* @memberof module:API.cvat.classes.Comment
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
id: {
|
||||
get: () => data.id,
|
||||
},
|
||||
/**
|
||||
* @name message
|
||||
* @type {string}
|
||||
* @memberof module:API.cvat.classes.Comment
|
||||
* @instance
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
*/
|
||||
message: {
|
||||
get: () => data.message,
|
||||
set: (value) => {
|
||||
if (!value.trim().length) {
|
||||
throw new ArgumentError('Value must not be empty');
|
||||
}
|
||||
data.message = value;
|
||||
},
|
||||
},
|
||||
/**
|
||||
* @name createdDate
|
||||
* @type {string}
|
||||
* @memberof module:API.cvat.classes.Comment
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
createdDate: {
|
||||
get: () => data.created_date,
|
||||
},
|
||||
/**
|
||||
* @name updatedDate
|
||||
* @type {string}
|
||||
* @memberof module:API.cvat.classes.Comment
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
updatedDate: {
|
||||
get: () => data.updated_date,
|
||||
},
|
||||
/**
|
||||
* Instance of a user who has created the comment
|
||||
* @name author
|
||||
* @type {module:API.cvat.classes.User}
|
||||
* @memberof module:API.cvat.classes.Comment
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
author: {
|
||||
get: () => data.author,
|
||||
},
|
||||
/**
|
||||
* @name removed
|
||||
* @type {boolean}
|
||||
* @memberof module:API.cvat.classes.Comment
|
||||
* @instance
|
||||
*/
|
||||
removed: {
|
||||
get: () => data.removed,
|
||||
set: (value) => {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new ArgumentError('Value must be a boolean value');
|
||||
}
|
||||
data.removed = value;
|
||||
},
|
||||
},
|
||||
__internal: {
|
||||
get: () => data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const data = {
|
||||
message: this.message,
|
||||
};
|
||||
|
||||
if (this.id > 0) {
|
||||
data.id = this.id;
|
||||
}
|
||||
if (this.createdDate) {
|
||||
data.created_date = this.createdDate;
|
||||
}
|
||||
if (this.updatedDate) {
|
||||
data.updated_date = this.updatedDate;
|
||||
}
|
||||
if (this.author) {
|
||||
data.author = this.author.serialize();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const data = this.serialize();
|
||||
const { author, ...updated } = data;
|
||||
return {
|
||||
...updated,
|
||||
author_id: author ? author.id : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = Comment;
|
||||
@ -0,0 +1,335 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const quickhull = require('quickhull');
|
||||
|
||||
const PluginRegistry = require('./plugins');
|
||||
const Comment = require('./comment');
|
||||
const User = require('./user');
|
||||
const { ArgumentError } = require('./exceptions');
|
||||
const { negativeIDGenerator } = require('./common');
|
||||
const serverProxy = require('./server-proxy');
|
||||
|
||||
/**
|
||||
* Class representing a single issue
|
||||
* @memberof module:API.cvat.classes
|
||||
* @hideconstructor
|
||||
*/
|
||||
class Issue {
|
||||
constructor(initialData) {
|
||||
const data = {
|
||||
id: undefined,
|
||||
position: undefined,
|
||||
comment_set: [],
|
||||
frame: undefined,
|
||||
created_date: undefined,
|
||||
resolved_date: undefined,
|
||||
owner: undefined,
|
||||
resolver: undefined,
|
||||
removed: false,
|
||||
};
|
||||
|
||||
for (const property in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) {
|
||||
data[property] = initialData[property];
|
||||
}
|
||||
}
|
||||
|
||||
if (data.owner && !(data.owner instanceof User)) data.owner = new User(data.owner);
|
||||
if (data.resolver && !(data.resolver instanceof User)) data.resolver = new User(data.resolver);
|
||||
|
||||
if (data.comment_set) {
|
||||
data.comment_set = data.comment_set.map((comment) => new Comment(comment));
|
||||
}
|
||||
|
||||
if (typeof data.id === 'undefined') {
|
||||
data.id = negativeIDGenerator();
|
||||
}
|
||||
if (typeof data.created_date === 'undefined') {
|
||||
data.created_date = new Date().toISOString();
|
||||
}
|
||||
|
||||
Object.defineProperties(
|
||||
this,
|
||||
Object.freeze({
|
||||
/**
|
||||
* @name id
|
||||
* @type {integer}
|
||||
* @memberof module:API.cvat.classes.Issue
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
id: {
|
||||
get: () => data.id,
|
||||
},
|
||||
/**
|
||||
* Region of interests of the issue
|
||||
* @name position
|
||||
* @type {number[]}
|
||||
* @memberof module:API.cvat.classes.Issue
|
||||
* @instance
|
||||
* @readonly
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
*/
|
||||
position: {
|
||||
get: () => data.position,
|
||||
set: (value) => {
|
||||
if (Array.isArray(value) || value.some((coord) => typeof coord !== 'number')) {
|
||||
throw new ArgumentError(`Array of numbers is expected. Got ${value}`);
|
||||
}
|
||||
data.position = value;
|
||||
},
|
||||
},
|
||||
/**
|
||||
* List of comments attached to the issue
|
||||
* @name comments
|
||||
* @type {module:API.cvat.classes.Comment[]}
|
||||
* @memberof module:API.cvat.classes.Issue
|
||||
* @instance
|
||||
* @readonly
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
*/
|
||||
comments: {
|
||||
get: () => data.comment_set.filter((comment) => !comment.removed),
|
||||
},
|
||||
/**
|
||||
* @name frame
|
||||
* @type {integer}
|
||||
* @memberof module:API.cvat.classes.Issue
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
frame: {
|
||||
get: () => data.frame,
|
||||
},
|
||||
/**
|
||||
* @name createdDate
|
||||
* @type {string}
|
||||
* @memberof module:API.cvat.classes.Issue
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
createdDate: {
|
||||
get: () => data.created_date,
|
||||
},
|
||||
/**
|
||||
* @name resolvedDate
|
||||
* @type {string}
|
||||
* @memberof module:API.cvat.classes.Issue
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
resolvedDate: {
|
||||
get: () => data.resolved_date,
|
||||
},
|
||||
/**
|
||||
* An instance of a user who has raised the issue
|
||||
* @name owner
|
||||
* @type {module:API.cvat.classes.User}
|
||||
* @memberof module:API.cvat.classes.Issue
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
owner: {
|
||||
get: () => data.owner,
|
||||
},
|
||||
/**
|
||||
* An instance of a user who has resolved the issue
|
||||
* @name resolver
|
||||
* @type {module:API.cvat.classes.User}
|
||||
* @memberof module:API.cvat.classes.Issue
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
resolver: {
|
||||
get: () => data.resolver,
|
||||
},
|
||||
/**
|
||||
* @name removed
|
||||
* @type {boolean}
|
||||
* @memberof module:API.cvat.classes.Comment
|
||||
* @instance
|
||||
*/
|
||||
removed: {
|
||||
get: () => data.removed,
|
||||
set: (value) => {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new ArgumentError('Value must be a boolean value');
|
||||
}
|
||||
data.removed = value;
|
||||
},
|
||||
},
|
||||
__internal: {
|
||||
get: () => data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
static hull(coordinates) {
|
||||
if (coordinates.length > 4) {
|
||||
const points = coordinates.reduce((acc, coord, index, arr) => {
|
||||
if (index % 2) acc.push({ x: arr[index - 1], y: coord });
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return quickhull(points)
|
||||
.map((point) => [point.x, point.y])
|
||||
.flat();
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} CommentData
|
||||
* @property {number} [author] an ID of a user who has created the comment
|
||||
* @property {string} message a comment message
|
||||
* @global
|
||||
*/
|
||||
/**
|
||||
* Method appends a comment to the issue
|
||||
* For a new issue it saves comment locally, for a saved issue it saves comment on the server
|
||||
* @method comment
|
||||
* @memberof module:API.cvat.classes.Issue
|
||||
* @param {CommentData} data
|
||||
* @readonly
|
||||
* @instance
|
||||
* @async
|
||||
* @throws {module:API.cvat.exceptions.ServerError}
|
||||
* @throws {module:API.cvat.exceptions.PluginError}
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
*/
|
||||
async comment(data) {
|
||||
const result = await PluginRegistry.apiWrapper.call(this, Issue.prototype.comment, data);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The method resolves the issue
|
||||
* New issues are resolved locally, server-saved issues are resolved on the server
|
||||
* @method resolve
|
||||
* @memberof module:API.cvat.classes.Issue
|
||||
* @param {module:API.cvat.classes.User} user
|
||||
* @readonly
|
||||
* @instance
|
||||
* @async
|
||||
* @throws {module:API.cvat.exceptions.ServerError}
|
||||
* @throws {module:API.cvat.exceptions.PluginError}
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
*/
|
||||
async resolve(user) {
|
||||
const result = await PluginRegistry.apiWrapper.call(this, Issue.prototype.resolve, user);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The method resolves the issue
|
||||
* New issues are reopened locally, server-saved issues are reopened on the server
|
||||
* @method reopen
|
||||
* @memberof module:API.cvat.classes.Issue
|
||||
* @readonly
|
||||
* @instance
|
||||
* @async
|
||||
* @throws {module:API.cvat.exceptions.ServerError}
|
||||
* @throws {module:API.cvat.exceptions.PluginError}
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
*/
|
||||
async reopen() {
|
||||
const result = await PluginRegistry.apiWrapper.call(this, Issue.prototype.reopen);
|
||||
return result;
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const { comments } = this;
|
||||
const data = {
|
||||
position: this.position,
|
||||
frame: this.frame,
|
||||
comment_set: comments.map((comment) => comment.serialize()),
|
||||
};
|
||||
|
||||
if (this.id > 0) {
|
||||
data.id = this.id;
|
||||
}
|
||||
if (this.createdDate) {
|
||||
data.created_date = this.createdDate;
|
||||
}
|
||||
if (this.resolvedDate) {
|
||||
data.resolved_date = this.resolvedDate;
|
||||
}
|
||||
if (this.owner) {
|
||||
data.owner = this.owner.toJSON();
|
||||
}
|
||||
if (this.resolver) {
|
||||
data.resolver = this.resolver.toJSON();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const data = this.serialize();
|
||||
const { owner, resolver, ...updated } = data;
|
||||
return {
|
||||
...updated,
|
||||
comment_set: this.comments.map((comment) => comment.toJSON()),
|
||||
owner_id: owner ? owner.id : undefined,
|
||||
resolver_id: resolver ? resolver.id : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Issue.prototype.comment.implementation = async function (data) {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
throw new ArgumentError(`The argument "data" must be a not null object. Got ${data}`);
|
||||
}
|
||||
if (typeof data.message !== 'string' || data.message.length < 1) {
|
||||
throw new ArgumentError(`Comment message must be a not empty string. Got ${data.message}`);
|
||||
}
|
||||
if (!(data.author instanceof User)) {
|
||||
throw new ArgumentError(`Author of the comment must a User instance. Got ${data.author}`);
|
||||
}
|
||||
|
||||
const comment = new Comment(data);
|
||||
const { id } = this;
|
||||
if (id >= 0) {
|
||||
const jsonified = comment.toJSON();
|
||||
jsonified.issue = id;
|
||||
const response = await serverProxy.comments.create(jsonified);
|
||||
const savedComment = new Comment(response);
|
||||
this.__internal.comment_set.push(savedComment);
|
||||
} else {
|
||||
this.__internal.comment_set.push(comment);
|
||||
}
|
||||
};
|
||||
|
||||
Issue.prototype.resolve.implementation = async function (user) {
|
||||
if (!(user instanceof User)) {
|
||||
throw new ArgumentError(`The argument "user" must be an instance of a User class. Got ${typeof user}`);
|
||||
}
|
||||
|
||||
const { id } = this;
|
||||
if (id >= 0) {
|
||||
const response = await serverProxy.issues.update(id, { resolver_id: user.id });
|
||||
this.__internal.resolved_date = response.resolved_date;
|
||||
this.__internal.resolver = new User(response.resolver);
|
||||
} else {
|
||||
this.__internal.resolved_date = new Date().toISOString();
|
||||
this.__internal.resolver = user;
|
||||
}
|
||||
};
|
||||
|
||||
Issue.prototype.reopen.implementation = async function () {
|
||||
const { id } = this;
|
||||
if (id >= 0) {
|
||||
const response = await serverProxy.issues.update(id, { resolver_id: null });
|
||||
this.__internal.resolved_date = response.resolved_date;
|
||||
this.__internal.resolver = response.resolver;
|
||||
} else {
|
||||
this.__internal.resolved_date = null;
|
||||
this.__internal.resolver = null;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Issue;
|
||||
@ -0,0 +1,397 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
const store = require('store');
|
||||
|
||||
const PluginRegistry = require('./plugins');
|
||||
const Issue = require('./issue');
|
||||
const User = require('./user');
|
||||
const { ArgumentError, DataError } = require('./exceptions');
|
||||
const { ReviewStatus } = require('./enums');
|
||||
const { negativeIDGenerator } = require('./common');
|
||||
const serverProxy = require('./server-proxy');
|
||||
|
||||
/**
|
||||
* Class representing a single review
|
||||
* @memberof module:API.cvat.classes
|
||||
* @hideconstructor
|
||||
*/
|
||||
class Review {
|
||||
constructor(initialData) {
|
||||
const data = {
|
||||
id: undefined,
|
||||
job: undefined,
|
||||
issue_set: [],
|
||||
estimated_quality: undefined,
|
||||
status: undefined,
|
||||
reviewer: undefined,
|
||||
assignee: undefined,
|
||||
reviewed_frames: undefined,
|
||||
reviewed_states: undefined,
|
||||
};
|
||||
|
||||
for (const property in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) {
|
||||
data[property] = initialData[property];
|
||||
}
|
||||
}
|
||||
|
||||
if (data.reviewer && !(data.reviewer instanceof User)) data.reviewer = new User(data.reviewer);
|
||||
if (data.assignee && !(data.assignee instanceof User)) data.assignee = new User(data.assignee);
|
||||
|
||||
data.reviewed_frames = Array.isArray(data.reviewed_frames) ? new Set(data.reviewed_frames) : new Set();
|
||||
data.reviewed_states = Array.isArray(data.reviewed_states) ? new Set(data.reviewed_states) : new Set();
|
||||
if (data.issue_set) {
|
||||
data.issue_set = data.issue_set.map((issue) => new Issue(issue));
|
||||
}
|
||||
|
||||
if (typeof data.id === 'undefined') {
|
||||
data.id = negativeIDGenerator();
|
||||
}
|
||||
|
||||
Object.defineProperties(
|
||||
this,
|
||||
Object.freeze({
|
||||
/**
|
||||
* @name id
|
||||
* @type {integer}
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
id: {
|
||||
get: () => data.id,
|
||||
},
|
||||
/**
|
||||
* An identifier of a job the review is attached to
|
||||
* @name job
|
||||
* @type {integer}
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
job: {
|
||||
get: () => data.job,
|
||||
},
|
||||
/**
|
||||
* List of attached issues
|
||||
* @name issues
|
||||
* @type {number[]}
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @instance
|
||||
* @readonly
|
||||
*/
|
||||
issues: {
|
||||
get: () => data.issue_set.filter((issue) => !issue.removed),
|
||||
},
|
||||
/**
|
||||
* Estimated quality of the review
|
||||
* @name estimatedQuality
|
||||
* @type {number}
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @instance
|
||||
* @readonly
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
*/
|
||||
estimatedQuality: {
|
||||
get: () => data.estimated_quality,
|
||||
set: (value) => {
|
||||
if (typeof value !== 'number' || value < 0 || value > 5) {
|
||||
throw new ArgumentError(`Value must be a number in range [0, 5]. Got ${value}`);
|
||||
}
|
||||
data.estimated_quality = value;
|
||||
},
|
||||
},
|
||||
/**
|
||||
* @name status
|
||||
* @type {module:API.cvat.enums.ReviewStatus}
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @instance
|
||||
* @readonly
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
*/
|
||||
status: {
|
||||
get: () => data.status,
|
||||
set: (status) => {
|
||||
const type = ReviewStatus;
|
||||
let valueInEnum = false;
|
||||
for (const value in type) {
|
||||
if (type[value] === status) {
|
||||
valueInEnum = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!valueInEnum) {
|
||||
throw new ArgumentError(
|
||||
'Value must be a value from the enumeration cvat.enums.ReviewStatus',
|
||||
);
|
||||
}
|
||||
|
||||
data.status = status;
|
||||
},
|
||||
},
|
||||
/**
|
||||
* An instance of a user who has done the review
|
||||
* @name reviewer
|
||||
* @type {module:API.cvat.classes.User}
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @readonly
|
||||
* @instance
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
*/
|
||||
reviewer: {
|
||||
get: () => data.reviewer,
|
||||
set: (reviewer) => {
|
||||
if (!(reviewer instanceof User)) {
|
||||
throw new ArgumentError(`Reviewer must be an instance of the User class. Got ${reviewer}`);
|
||||
}
|
||||
|
||||
data.reviewer = reviewer;
|
||||
},
|
||||
},
|
||||
/**
|
||||
* An instance of a user who was assigned for annotation before the review
|
||||
* @name assignee
|
||||
* @type {module:API.cvat.classes.User}
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
assignee: {
|
||||
get: () => data.assignee,
|
||||
},
|
||||
/**
|
||||
* A set of frames that have been visited during review
|
||||
* @name reviewedFrames
|
||||
* @type {number[]}
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
reviewedFrames: {
|
||||
get: () => Array.from(data.reviewed_frames),
|
||||
},
|
||||
/**
|
||||
* A set of reviewed states (server IDs combined with frames)
|
||||
* @name reviewedFrames
|
||||
* @type {string[]}
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @readonly
|
||||
* @instance
|
||||
*/
|
||||
reviewedStates: {
|
||||
get: () => Array.from(data.reviewed_states),
|
||||
},
|
||||
__internal: {
|
||||
get: () => data,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method appends a frame to a set of reviewed frames
|
||||
* Reviewed frames are saved only in local storage
|
||||
* @method reviewFrame
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @param {number} frame
|
||||
* @readonly
|
||||
* @instance
|
||||
* @async
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
* @throws {module:API.cvat.exceptions.PluginError}
|
||||
*/
|
||||
async reviewFrame(frame) {
|
||||
const result = await PluginRegistry.apiWrapper.call(this, Review.prototype.reviewFrame, frame);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method appends a frame to a set of reviewed frames
|
||||
* Reviewed states are saved only in local storage. They are used to automatic annotations quality assessment
|
||||
* @method reviewStates
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @param {string[]} stateIDs
|
||||
* @readonly
|
||||
* @instance
|
||||
* @async
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
* @throws {module:API.cvat.exceptions.PluginError}
|
||||
*/
|
||||
async reviewStates(stateIDs) {
|
||||
const result = await PluginRegistry.apiWrapper.call(this, Review.prototype.reviewStates, stateIDs);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} IssueData
|
||||
* @property {number} frame
|
||||
* @property {number[]} position
|
||||
* @property {number} owner
|
||||
* @property {CommentData[]} comment_set
|
||||
* @global
|
||||
*/
|
||||
/**
|
||||
* Method adds a new issue to the review
|
||||
* @method openIssue
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @param {IssueData} data
|
||||
* @returns {module:API.cvat.classes.Issue}
|
||||
* @readonly
|
||||
* @instance
|
||||
* @async
|
||||
* @throws {module:API.cvat.exceptions.ArgumentError}
|
||||
* @throws {module:API.cvat.exceptions.PluginError}
|
||||
*/
|
||||
async openIssue(data) {
|
||||
const result = await PluginRegistry.apiWrapper.call(this, Review.prototype.openIssue, data);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Method submits local review to the server
|
||||
* @method submit
|
||||
* @memberof module:API.cvat.classes.Review
|
||||
* @readonly
|
||||
* @instance
|
||||
* @async
|
||||
* @throws {module:API.cvat.exceptions.DataError}
|
||||
* @throws {module:API.cvat.exceptions.PluginError}
|
||||
*/
|
||||
async submit() {
|
||||
const result = await PluginRegistry.apiWrapper.call(this, Review.prototype.submit);
|
||||
return result;
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const { issues, reviewedFrames, reviewedStates } = this;
|
||||
const data = {
|
||||
job: this.job,
|
||||
issue_set: issues.map((issue) => issue.serialize()),
|
||||
reviewed_frames: Array.from(reviewedFrames),
|
||||
reviewed_states: Array.from(reviewedStates),
|
||||
};
|
||||
|
||||
if (this.id > 0) {
|
||||
data.id = this.id;
|
||||
}
|
||||
if (typeof this.estimatedQuality !== 'undefined') {
|
||||
data.estimated_quality = this.estimatedQuality;
|
||||
}
|
||||
if (typeof this.status !== 'undefined') {
|
||||
data.status = this.status;
|
||||
}
|
||||
if (this.reviewer) {
|
||||
data.reviewer = this.reviewer.toJSON();
|
||||
}
|
||||
if (this.assignee) {
|
||||
data.reviewer = this.assignee.toJSON();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
const data = this.serialize();
|
||||
const {
|
||||
reviewer,
|
||||
assignee,
|
||||
reviewed_frames: reviewedFrames,
|
||||
reviewed_states: reviewedStates,
|
||||
...updated
|
||||
} = data;
|
||||
|
||||
return {
|
||||
...updated,
|
||||
issue_set: this.issues.map((issue) => issue.toJSON()),
|
||||
reviewer_id: reviewer ? reviewer.id : undefined,
|
||||
assignee_id: assignee ? assignee.id : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async toLocalStorage() {
|
||||
const data = this.serialize();
|
||||
store.set(`job-${this.job}-review`, JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
Review.prototype.reviewFrame.implementation = function (frame) {
|
||||
if (!Number.isInteger(frame)) {
|
||||
throw new ArgumentError(`The argument "frame" is expected to be an integer. Got ${frame}`);
|
||||
}
|
||||
this.__internal.reviewed_frames.add(frame);
|
||||
};
|
||||
|
||||
Review.prototype.reviewStates.implementation = function (stateIDs) {
|
||||
if (!Array.isArray(stateIDs) || stateIDs.some((stateID) => typeof stateID !== 'string')) {
|
||||
throw new ArgumentError(`The argument "stateIDs" is expected to be an array of string. Got ${stateIDs}`);
|
||||
}
|
||||
|
||||
stateIDs.forEach((stateID) => this.__internal.reviewed_states.add(stateID));
|
||||
};
|
||||
|
||||
Review.prototype.openIssue.implementation = async function (data) {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
throw new ArgumentError(`The argument "data" must be a not null object. Got ${data}`);
|
||||
}
|
||||
|
||||
if (typeof data.frame !== 'number') {
|
||||
throw new ArgumentError(`Issue frame must be a number. Got ${data.frame}`);
|
||||
}
|
||||
|
||||
if (!(data.owner instanceof User)) {
|
||||
throw new ArgumentError(`Issue owner must be a User instance. Got ${data.owner}`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.position) || data.position.some((coord) => typeof coord !== 'number')) {
|
||||
throw new ArgumentError(`Issue position must be an array of numbers. Got ${data.position}`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.comment_set)) {
|
||||
throw new ArgumentError(`Issue comment set must be an array. Got ${data.comment_set}`);
|
||||
}
|
||||
|
||||
const copied = {
|
||||
frame: data.frame,
|
||||
position: Issue.hull(data.position),
|
||||
owner: data.owner,
|
||||
comment_set: [],
|
||||
};
|
||||
|
||||
const issue = new Issue(copied);
|
||||
|
||||
for (const comment of data.comment_set) {
|
||||
await issue.comment.implementation.call(issue, comment);
|
||||
}
|
||||
|
||||
this.__internal.issue_set.push(issue);
|
||||
return issue;
|
||||
};
|
||||
|
||||
Review.prototype.submit.implementation = async function () {
|
||||
if (typeof this.estimatedQuality === 'undefined') {
|
||||
throw new DataError('Estimated quality is expected to be a number. Got "undefined"');
|
||||
}
|
||||
|
||||
if (typeof this.status === 'undefined') {
|
||||
throw new DataError('Review status is expected to be a string. Got "undefined"');
|
||||
}
|
||||
|
||||
if (this.id < 0) {
|
||||
const data = this.toJSON();
|
||||
|
||||
const response = await serverProxy.jobs.reviews.create(data);
|
||||
store.remove(`job-${this.job}-review`);
|
||||
this.__internal.id = response.id;
|
||||
this.__internal.issue_set = response.issue_set.map((issue) => new Issue(issue));
|
||||
this.__internal.estimated_quality = response.estimated_quality;
|
||||
this.__internal.status = response.status;
|
||||
|
||||
if (response.reviewer) this.__internal.reviewer = new User(response.reviewer);
|
||||
if (response.assignee) this.__internal.assignee = new User(response.assignee);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = Review;
|
||||
@ -0,0 +1,217 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { ActionUnion, createAction, ThunkAction } from 'utils/redux';
|
||||
import getCore from 'cvat-core-wrapper';
|
||||
import { updateTaskSuccess } from './tasks-actions';
|
||||
|
||||
const cvat = getCore();
|
||||
|
||||
export enum ReviewActionTypes {
|
||||
INITIALIZE_REVIEW_SUCCESS = 'INITIALIZE_REVIEW_SUCCESS',
|
||||
INITIALIZE_REVIEW_FAILED = 'INITIALIZE_REVIEW_FAILED',
|
||||
CREATE_ISSUE = 'CREATE_ISSUE',
|
||||
START_ISSUE = 'START_ISSUE',
|
||||
FINISH_ISSUE_SUCCESS = 'FINISH_ISSUE_SUCCESS',
|
||||
FINISH_ISSUE_FAILED = 'FINISH_ISSUE_FAILED',
|
||||
CANCEL_ISSUE = 'CANCEL_ISSUE',
|
||||
RESOLVE_ISSUE = 'RESOLVE_ISSUE',
|
||||
RESOLVE_ISSUE_SUCCESS = 'RESOLVE_ISSUE_SUCCESS',
|
||||
RESOLVE_ISSUE_FAILED = 'RESOLVE_ISSUE_FAILED',
|
||||
REOPEN_ISSUE = 'REOPEN_ISSUE',
|
||||
REOPEN_ISSUE_SUCCESS = 'REOPEN_ISSUE_SUCCESS',
|
||||
REOPEN_ISSUE_FAILED = 'REOPEN_ISSUE_FAILED',
|
||||
COMMENT_ISSUE = 'COMMENT_ISSUE',
|
||||
COMMENT_ISSUE_SUCCESS = 'COMMENT_ISSUE_SUCCESS',
|
||||
COMMENT_ISSUE_FAILED = 'COMMENT_ISSUE_FAILED',
|
||||
SUBMIT_REVIEW = 'SUBMIT_REVIEW',
|
||||
SUBMIT_REVIEW_SUCCESS = 'SUBMIT_REVIEW_SUCCESS',
|
||||
SUBMIT_REVIEW_FAILED = 'SUBMIT_REVIEW_FAILED',
|
||||
SWITCH_ISSUES_HIDDEN_FLAG = 'SWITCH_ISSUES_HIDDEN_FLAG',
|
||||
}
|
||||
|
||||
export const reviewActions = {
|
||||
initializeReviewSuccess: (reviewInstance: any, frame: number) =>
|
||||
createAction(ReviewActionTypes.INITIALIZE_REVIEW_SUCCESS, { reviewInstance, frame }),
|
||||
initializeReviewFailed: (error: any) => createAction(ReviewActionTypes.INITIALIZE_REVIEW_FAILED, { error }),
|
||||
createIssue: () => createAction(ReviewActionTypes.CREATE_ISSUE, {}),
|
||||
startIssue: (position: number[]) =>
|
||||
createAction(ReviewActionTypes.START_ISSUE, { position: cvat.classes.Issue.hull(position) }),
|
||||
finishIssueSuccess: (frame: number, issue: any) =>
|
||||
createAction(ReviewActionTypes.FINISH_ISSUE_SUCCESS, { frame, issue }),
|
||||
finishIssueFailed: (error: any) => createAction(ReviewActionTypes.FINISH_ISSUE_FAILED, { error }),
|
||||
cancelIssue: () => createAction(ReviewActionTypes.CANCEL_ISSUE),
|
||||
commentIssue: (issueId: number) => createAction(ReviewActionTypes.COMMENT_ISSUE, { issueId }),
|
||||
commentIssueSuccess: () => createAction(ReviewActionTypes.COMMENT_ISSUE_SUCCESS),
|
||||
commentIssueFailed: (error: any) => createAction(ReviewActionTypes.COMMENT_ISSUE_FAILED, { error }),
|
||||
resolveIssue: (issueId: number) => createAction(ReviewActionTypes.RESOLVE_ISSUE, { issueId }),
|
||||
resolveIssueSuccess: () => createAction(ReviewActionTypes.RESOLVE_ISSUE_SUCCESS),
|
||||
resolveIssueFailed: (error: any) => createAction(ReviewActionTypes.RESOLVE_ISSUE_FAILED, { error }),
|
||||
reopenIssue: (issueId: number) => createAction(ReviewActionTypes.REOPEN_ISSUE, { issueId }),
|
||||
reopenIssueSuccess: () => createAction(ReviewActionTypes.REOPEN_ISSUE_SUCCESS),
|
||||
reopenIssueFailed: (error: any) => createAction(ReviewActionTypes.REOPEN_ISSUE_FAILED, { error }),
|
||||
submitReview: (reviewId: number) => createAction(ReviewActionTypes.SUBMIT_REVIEW, { reviewId }),
|
||||
submitReviewSuccess: (activeReview: any, reviews: any[], issues: any[], frame: number) =>
|
||||
createAction(ReviewActionTypes.SUBMIT_REVIEW_SUCCESS, {
|
||||
activeReview,
|
||||
reviews,
|
||||
issues,
|
||||
frame,
|
||||
}),
|
||||
submitReviewFailed: (error: any) => createAction(ReviewActionTypes.SUBMIT_REVIEW_FAILED, { error }),
|
||||
switchIssuesHiddenFlag: (hidden: boolean) => createAction(ReviewActionTypes.SWITCH_ISSUES_HIDDEN_FLAG, { hidden }),
|
||||
};
|
||||
|
||||
export type ReviewActions = ActionUnion<typeof reviewActions>;
|
||||
|
||||
export const initializeReviewAsync = (): ThunkAction => async (dispatch, getState) => {
|
||||
try {
|
||||
const state = getState();
|
||||
const {
|
||||
annotation: {
|
||||
job: { instance: jobInstance },
|
||||
player: {
|
||||
frame: { number: frame },
|
||||
},
|
||||
},
|
||||
} = state;
|
||||
|
||||
const reviews = await jobInstance.reviews();
|
||||
const count = reviews.length;
|
||||
let reviewInstance = null;
|
||||
if (count && reviews[count - 1].id < 0) {
|
||||
reviewInstance = reviews[count - 1];
|
||||
} else {
|
||||
reviewInstance = new cvat.classes.Review({ job: jobInstance.id });
|
||||
}
|
||||
|
||||
dispatch(reviewActions.initializeReviewSuccess(reviewInstance, frame));
|
||||
} catch (error) {
|
||||
dispatch(reviewActions.initializeReviewFailed(error));
|
||||
}
|
||||
};
|
||||
|
||||
export const finishIssueAsync = (message: string): ThunkAction => async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {
|
||||
auth: { user },
|
||||
annotation: {
|
||||
player: {
|
||||
frame: { number: frameNumber },
|
||||
},
|
||||
},
|
||||
review: { activeReview, newIssuePosition },
|
||||
} = state;
|
||||
|
||||
try {
|
||||
const issue = await activeReview.openIssue({
|
||||
frame: frameNumber,
|
||||
position: newIssuePosition,
|
||||
owner: user,
|
||||
comment_set: [
|
||||
{
|
||||
message,
|
||||
author: user,
|
||||
},
|
||||
],
|
||||
});
|
||||
await activeReview.toLocalStorage();
|
||||
dispatch(reviewActions.finishIssueSuccess(frameNumber, issue));
|
||||
} catch (error) {
|
||||
dispatch(reviewActions.finishIssueFailed(error));
|
||||
}
|
||||
};
|
||||
|
||||
export const commentIssueAsync = (id: number, message: string): ThunkAction => async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {
|
||||
auth: { user },
|
||||
review: { frameIssues, activeReview },
|
||||
} = state;
|
||||
|
||||
try {
|
||||
dispatch(reviewActions.commentIssue(id));
|
||||
const [issue] = frameIssues.filter((_issue: any): boolean => _issue.id === id);
|
||||
await issue.comment({
|
||||
message,
|
||||
author: user,
|
||||
});
|
||||
if (activeReview && activeReview.issues.includes(issue)) {
|
||||
await activeReview.toLocalStorage();
|
||||
}
|
||||
dispatch(reviewActions.commentIssueSuccess());
|
||||
} catch (error) {
|
||||
dispatch(reviewActions.commentIssueFailed(error));
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveIssueAsync = (id: number): ThunkAction => async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {
|
||||
auth: { user },
|
||||
review: { frameIssues, activeReview },
|
||||
} = state;
|
||||
|
||||
try {
|
||||
dispatch(reviewActions.resolveIssue(id));
|
||||
const [issue] = frameIssues.filter((_issue: any): boolean => _issue.id === id);
|
||||
await issue.resolve(user);
|
||||
if (activeReview && activeReview.issues.includes(issue)) {
|
||||
await activeReview.toLocalStorage();
|
||||
}
|
||||
|
||||
dispatch(reviewActions.resolveIssueSuccess());
|
||||
} catch (error) {
|
||||
dispatch(reviewActions.resolveIssueFailed(error));
|
||||
}
|
||||
};
|
||||
|
||||
export const reopenIssueAsync = (id: number): ThunkAction => async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {
|
||||
auth: { user },
|
||||
review: { frameIssues, activeReview },
|
||||
} = state;
|
||||
|
||||
try {
|
||||
dispatch(reviewActions.reopenIssue(id));
|
||||
const [issue] = frameIssues.filter((_issue: any): boolean => _issue.id === id);
|
||||
await issue.reopen(user);
|
||||
if (activeReview && activeReview.issues.includes(issue)) {
|
||||
await activeReview.toLocalStorage();
|
||||
}
|
||||
|
||||
dispatch(reviewActions.reopenIssueSuccess());
|
||||
} catch (error) {
|
||||
dispatch(reviewActions.reopenIssueFailed(error));
|
||||
}
|
||||
};
|
||||
|
||||
export const submitReviewAsync = (review: any): ThunkAction => async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const {
|
||||
annotation: {
|
||||
job: { instance: jobInstance },
|
||||
player: {
|
||||
frame: { number: frame },
|
||||
},
|
||||
},
|
||||
} = state;
|
||||
|
||||
try {
|
||||
dispatch(reviewActions.submitReview(review.id));
|
||||
await review.submit(jobInstance.id);
|
||||
|
||||
const [task] = await cvat.tasks.get({ id: jobInstance.task.id });
|
||||
dispatch(updateTaskSuccess(task));
|
||||
|
||||
const reviews = await jobInstance.reviews();
|
||||
const issues = await jobInstance.issues();
|
||||
const reviewInstance = new cvat.classes.Review({ job: jobInstance.id });
|
||||
|
||||
dispatch(reviewActions.submitReviewSuccess(reviewInstance, reviews, issues, frame));
|
||||
} catch (error) {
|
||||
dispatch(reviewActions.submitReviewFailed(error));
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,140 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Menu, { ClickParam } from 'antd/lib/menu';
|
||||
|
||||
import ObjectItemContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/object-item';
|
||||
import { Workspace } from 'reducers/interfaces';
|
||||
import consts from 'consts';
|
||||
|
||||
interface Props {
|
||||
readonly: boolean;
|
||||
workspace: Workspace;
|
||||
contextMenuClientID: number | null;
|
||||
objectStates: any[];
|
||||
visible: boolean;
|
||||
left: number;
|
||||
top: number;
|
||||
onStartIssue(position: number[]): void;
|
||||
openIssue(position: number[], message: string): void;
|
||||
latestComments: string[];
|
||||
}
|
||||
|
||||
interface ReviewContextMenuProps {
|
||||
top: number;
|
||||
left: number;
|
||||
latestComments: string[];
|
||||
onClick: (param: ClickParam) => void;
|
||||
}
|
||||
|
||||
enum ReviewContextMenuKeys {
|
||||
OPEN_ISSUE = 'open_issue',
|
||||
QUICK_ISSUE_POSITION = 'quick_issue_position',
|
||||
QUICK_ISSUE_ATTRIBUTE = 'quick_issue_attribute',
|
||||
QUICK_ISSUE_FROM_LATEST = 'quick_issue_from_latest',
|
||||
}
|
||||
|
||||
function ReviewContextMenu({
|
||||
top, left, latestComments, onClick,
|
||||
}: ReviewContextMenuProps): JSX.Element {
|
||||
return (
|
||||
<Menu onClick={onClick} selectable={false} className='cvat-canvas-context-menu' style={{ top, left }}>
|
||||
<Menu.Item className='cvat-context-menu-item' key={ReviewContextMenuKeys.OPEN_ISSUE}>
|
||||
Open an issue ...
|
||||
</Menu.Item>
|
||||
<Menu.Item className='cvat-context-menu-item' key={ReviewContextMenuKeys.QUICK_ISSUE_POSITION}>
|
||||
Quick issue: incorrect position
|
||||
</Menu.Item>
|
||||
<Menu.Item className='cvat-context-menu-item' key={ReviewContextMenuKeys.QUICK_ISSUE_ATTRIBUTE}>
|
||||
Quick issue: incorrect attribute
|
||||
</Menu.Item>
|
||||
{latestComments.length ? (
|
||||
<Menu.SubMenu
|
||||
title='Quick issue ...'
|
||||
className='cvat-context-menu-item'
|
||||
key={ReviewContextMenuKeys.QUICK_ISSUE_FROM_LATEST}
|
||||
>
|
||||
{latestComments.map(
|
||||
(comment: string, id: number): JSX.Element => (
|
||||
<Menu.Item className='cvat-context-menu-item' key={`${id}`}>
|
||||
{comment}
|
||||
</Menu.Item>
|
||||
),
|
||||
)}
|
||||
</Menu.SubMenu>
|
||||
) : null}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CanvasContextMenu(props: Props): JSX.Element | null {
|
||||
const {
|
||||
contextMenuClientID,
|
||||
objectStates,
|
||||
visible,
|
||||
left,
|
||||
top,
|
||||
readonly,
|
||||
workspace,
|
||||
latestComments,
|
||||
onStartIssue,
|
||||
openIssue,
|
||||
} = props;
|
||||
|
||||
if (!visible || contextMenuClientID === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (workspace === Workspace.REVIEW_WORKSPACE) {
|
||||
return ReactDOM.createPortal(
|
||||
<ReviewContextMenu
|
||||
key={contextMenuClientID}
|
||||
top={top}
|
||||
left={left}
|
||||
latestComments={latestComments}
|
||||
onClick={(param: ClickParam) => {
|
||||
const [state] = objectStates.filter(
|
||||
(_state: any): boolean => _state.clientID === contextMenuClientID,
|
||||
);
|
||||
if (param.key === ReviewContextMenuKeys.OPEN_ISSUE) {
|
||||
if (state) {
|
||||
onStartIssue(state.points);
|
||||
}
|
||||
} else if (param.key === ReviewContextMenuKeys.QUICK_ISSUE_POSITION) {
|
||||
if (state) {
|
||||
openIssue(state.points, consts.QUICK_ISSUE_INCORRECT_POSITION_TEXT);
|
||||
}
|
||||
} else if (param.key === ReviewContextMenuKeys.QUICK_ISSUE_ATTRIBUTE) {
|
||||
if (state) {
|
||||
openIssue(state.points, consts.QUICK_ISSUE_INCORRECT_ATTRIBUTE_TEXT);
|
||||
}
|
||||
} else if (
|
||||
param.keyPath.length === 2 &&
|
||||
param.keyPath[1] === ReviewContextMenuKeys.QUICK_ISSUE_FROM_LATEST
|
||||
) {
|
||||
if (state) {
|
||||
openIssue(state.points, latestComments[+param.keyPath[0]]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>,
|
||||
window.document.body,
|
||||
);
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className='cvat-canvas-context-menu' style={{ top, left }}>
|
||||
<ObjectItemContainer
|
||||
readonly={readonly}
|
||||
key={contextMenuClientID}
|
||||
clientID={contextMenuClientID}
|
||||
objectStates={objectStates}
|
||||
initialCollapsed
|
||||
/>
|
||||
</div>,
|
||||
window.document.body,
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { AnyAction } from 'redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useHistory } from 'react-router';
|
||||
import Text from 'antd/lib/typography/Text';
|
||||
import Title from 'antd/lib/typography/Title';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import { Row, Col } from 'antd/lib/grid';
|
||||
|
||||
import UserSelector, { User } from 'components/task-page/user-selector';
|
||||
import { CombinedState, TaskStatus } from 'reducers/interfaces';
|
||||
import { switchRequestReviewDialog } from 'actions/annotation-actions';
|
||||
import { updateJobAsync } from 'actions/tasks-actions';
|
||||
|
||||
export default function RequestReviewModal(): JSX.Element | null {
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const isVisible = useSelector((state: CombinedState): boolean => state.annotation.requestReviewDialogVisible);
|
||||
const job = useSelector((state: CombinedState): any => state.annotation.job.instance);
|
||||
const [reviewer, setReviewer] = useState<User | null>(job.reviewer ? job.reviewer : null);
|
||||
const close = (): AnyAction => dispatch(switchRequestReviewDialog(false));
|
||||
const submitAnnotations = (): void => {
|
||||
job.reviewer = reviewer;
|
||||
job.status = TaskStatus.REVIEW;
|
||||
dispatch(updateJobAsync(job));
|
||||
history.push(`/tasks/${job.task.id}`);
|
||||
};
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className='cvat-request-review-dialog'
|
||||
visible={isVisible}
|
||||
destroyOnClose
|
||||
onCancel={close}
|
||||
onOk={submitAnnotations}
|
||||
okText='Submit'
|
||||
>
|
||||
<Row type='flex' justify='start'>
|
||||
<Col>
|
||||
<Title level={4}>Assign a user who is responsible for review</Title>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row align='middle' type='flex' justify='start'>
|
||||
<Col>
|
||||
<Text type='secondary'>Reviewer: </Text>
|
||||
</Col>
|
||||
<Col offset={1}>
|
||||
<UserSelector value={reviewer} onSelect={setReviewer} />
|
||||
</Col>
|
||||
</Row>
|
||||
<Row type='flex' justify='start'>
|
||||
<Text type='secondary'>You might not be able to change the job after this action. Continue?</Text>
|
||||
</Row>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React from 'react';
|
||||
import { GlobalHotKeys, ExtendedKeyMapOptions } from 'react-hotkeys';
|
||||
import Layout from 'antd/lib/layout';
|
||||
|
||||
import { ActiveControl, Rotation } from 'reducers/interfaces';
|
||||
import { Canvas } from 'cvat-canvas-wrapper';
|
||||
|
||||
import RotateControl from 'components/annotation-page/standard-workspace/controls-side-bar/rotate-control';
|
||||
import CursorControl from 'components/annotation-page/standard-workspace/controls-side-bar/cursor-control';
|
||||
import MoveControl from 'components/annotation-page/standard-workspace/controls-side-bar/move-control';
|
||||
import FitControl from 'components/annotation-page/standard-workspace/controls-side-bar/fit-control';
|
||||
import ResizeControl from 'components/annotation-page/standard-workspace/controls-side-bar/resize-control';
|
||||
import IssueControl from './issue-control';
|
||||
|
||||
interface Props {
|
||||
canvasInstance: Canvas;
|
||||
activeControl: ActiveControl;
|
||||
keyMap: Record<string, ExtendedKeyMapOptions>;
|
||||
normalizedKeyMap: Record<string, string>;
|
||||
|
||||
rotateFrame(rotation: Rotation): void;
|
||||
selectIssuePosition(enabled: boolean): void;
|
||||
}
|
||||
|
||||
export default function ControlsSideBarComponent(props: Props): JSX.Element {
|
||||
const {
|
||||
canvasInstance, activeControl, normalizedKeyMap, keyMap, rotateFrame, selectIssuePosition,
|
||||
} = props;
|
||||
|
||||
const preventDefault = (event: KeyboardEvent | undefined): void => {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const subKeyMap = {
|
||||
CANCEL: keyMap.CANCEL,
|
||||
OPEN_REVIEW_ISSUE: keyMap.OPEN_REVIEW_ISSUE,
|
||||
};
|
||||
|
||||
const handlers = {
|
||||
CANCEL: (event: KeyboardEvent | undefined) => {
|
||||
preventDefault(event);
|
||||
if (activeControl !== ActiveControl.CURSOR) {
|
||||
canvasInstance.cancel();
|
||||
}
|
||||
},
|
||||
OPEN_REVIEW_ISSUE: (event: KeyboardEvent | undefined) => {
|
||||
preventDefault(event);
|
||||
if (activeControl === ActiveControl.OPEN_ISSUE) {
|
||||
canvasInstance.selectRegion(false);
|
||||
selectIssuePosition(false);
|
||||
} else {
|
||||
canvasInstance.cancel();
|
||||
canvasInstance.selectRegion(true);
|
||||
selectIssuePosition(true);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout.Sider className='cvat-canvas-controls-sidebar' theme='light' width={44}>
|
||||
<GlobalHotKeys keyMap={subKeyMap} handlers={handlers} allowChanges />
|
||||
<CursorControl
|
||||
cursorShortkey={normalizedKeyMap.CANCEL}
|
||||
canvasInstance={canvasInstance}
|
||||
activeControl={activeControl}
|
||||
/>
|
||||
<MoveControl canvasInstance={canvasInstance} activeControl={activeControl} />
|
||||
<RotateControl
|
||||
anticlockwiseShortcut={normalizedKeyMap.ANTICLOCKWISE_ROTATION}
|
||||
clockwiseShortcut={normalizedKeyMap.CLOCKWISE_ROTATION}
|
||||
rotateFrame={rotateFrame}
|
||||
/>
|
||||
|
||||
<hr />
|
||||
|
||||
<FitControl canvasInstance={canvasInstance} />
|
||||
<ResizeControl canvasInstance={canvasInstance} activeControl={activeControl} />
|
||||
|
||||
<hr />
|
||||
<IssueControl
|
||||
canvasInstance={canvasInstance}
|
||||
activeControl={activeControl}
|
||||
selectIssuePosition={selectIssuePosition}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React from 'react';
|
||||
import Icon from 'antd/lib/icon';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
|
||||
import { ActiveControl } from 'reducers/interfaces';
|
||||
import { Canvas } from 'cvat-canvas-wrapper';
|
||||
import { RectangleIcon } from 'icons';
|
||||
|
||||
interface Props {
|
||||
canvasInstance: Canvas;
|
||||
activeControl: ActiveControl;
|
||||
selectIssuePosition(enabled: boolean): void;
|
||||
}
|
||||
|
||||
function ResizeControl(props: Props): JSX.Element {
|
||||
const { activeControl, canvasInstance, selectIssuePosition } = props;
|
||||
|
||||
return (
|
||||
<Tooltip title='Open an issue' placement='right' mouseLeaveDelay={0}>
|
||||
<Icon
|
||||
component={RectangleIcon}
|
||||
className={
|
||||
activeControl === ActiveControl.OPEN_ISSUE ?
|
||||
'cvat-issue-control cvat-active-canvas-control' :
|
||||
'cvat-issue-control'
|
||||
}
|
||||
onClick={(): void => {
|
||||
if (activeControl === ActiveControl.OPEN_ISSUE) {
|
||||
canvasInstance.selectRegion(false);
|
||||
selectIssuePosition(false);
|
||||
} else {
|
||||
canvasInstance.cancel();
|
||||
canvasInstance.selectRegion(true);
|
||||
selectIssuePosition(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ResizeControl);
|
||||
@ -0,0 +1,50 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import './styles.scss';
|
||||
import React, { useEffect } from 'react';
|
||||
import Layout from 'antd/lib/layout';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { CombinedState } from 'reducers/interfaces';
|
||||
import { initializeReviewAsync } from 'actions/review-actions';
|
||||
|
||||
import CanvasWrapperContainer from 'containers/annotation-page/canvas/canvas-wrapper';
|
||||
import ControlsSideBarContainer from 'containers/annotation-page/review-workspace/controls-side-bar/controls-side-bar';
|
||||
import ObjectSideBarComponent from 'components/annotation-page/standard-workspace/objects-side-bar/objects-side-bar';
|
||||
import ObjectsListContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/objects-list';
|
||||
import CanvasContextMenuContainer from 'containers/annotation-page/canvas/canvas-context-menu';
|
||||
import IssueAggregatorComponent from 'components/annotation-page/review/issues-aggregator';
|
||||
|
||||
export default function ReviewWorkspaceComponent(): JSX.Element {
|
||||
const dispatch = useDispatch();
|
||||
const frame = useSelector((state: CombinedState): number => state.annotation.player.frame.number);
|
||||
const states = useSelector((state: CombinedState): any[] => state.annotation.annotations.states);
|
||||
const review = useSelector((state: CombinedState): any => state.review.activeReview);
|
||||
|
||||
useEffect(() => {
|
||||
if (review) {
|
||||
review.reviewFrame(frame);
|
||||
review.reviewStates(
|
||||
states
|
||||
.map((state: any): number | undefined => state.serverID)
|
||||
.filter((serverID: number | undefined): boolean => typeof serverID !== 'undefined')
|
||||
.map((serverID: number | undefined): string => `${frame}_${serverID}`),
|
||||
);
|
||||
}
|
||||
}, [frame, states, review]);
|
||||
useEffect(() => {
|
||||
dispatch(initializeReviewAsync());
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout hasSider className='cvat-review-workspace'>
|
||||
<ControlsSideBarContainer />
|
||||
<CanvasWrapperContainer />
|
||||
<ObjectSideBarComponent objectsList={<ObjectsListContainer readonly />} />
|
||||
<CanvasContextMenuContainer readonly />
|
||||
<IssueAggregatorComponent />
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
@import 'base.scss';
|
||||
|
||||
.cvat-review-workspace.ant-layout {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cvat-issue-control {
|
||||
font-size: 40px;
|
||||
|
||||
&::after {
|
||||
content: '\FE56';
|
||||
font-size: 32px;
|
||||
position: absolute;
|
||||
bottom: $grid-unit-size;
|
||||
right: -$grid-unit-size;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React, { ReactPortal } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Form, { FormComponentProps } from 'antd/lib/form';
|
||||
import Input from 'antd/lib/input';
|
||||
import Button from 'antd/lib/button';
|
||||
import { Row, Col } from 'antd/lib/grid';
|
||||
|
||||
import { reviewActions, finishIssueAsync } from 'actions/review-actions';
|
||||
|
||||
type FormProps = {
|
||||
top: number;
|
||||
left: number;
|
||||
submit(message: string): void;
|
||||
cancel(): void;
|
||||
} & FormComponentProps;
|
||||
|
||||
function MessageForm(props: FormProps): JSX.Element {
|
||||
const {
|
||||
form: { getFieldDecorator },
|
||||
form,
|
||||
top,
|
||||
left,
|
||||
submit,
|
||||
cancel,
|
||||
} = props;
|
||||
|
||||
function handleSubmit(e: React.FormEvent): void {
|
||||
e.preventDefault();
|
||||
form.validateFields((error, values): void => {
|
||||
if (!error) {
|
||||
submit(values.issue_description);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form className='cvat-create-issue-dialog' style={{ top, left }} onSubmit={handleSubmit}>
|
||||
<Form.Item>
|
||||
{getFieldDecorator('issue_description', {
|
||||
rules: [{ required: true, message: 'Please, fill out the field' }],
|
||||
})(<Input autoComplete='off' placeholder='Please, describe the issue' />)}
|
||||
</Form.Item>
|
||||
<Row type='flex' justify='space-between'>
|
||||
<Col>
|
||||
<Button onClick={cancel} type='ghost'>
|
||||
Cancel
|
||||
</Button>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button type='primary' htmlType='submit'>
|
||||
Submit
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
const WrappedMessageForm = Form.create<FormProps>()(MessageForm);
|
||||
|
||||
interface Props {
|
||||
top: number;
|
||||
left: number;
|
||||
}
|
||||
|
||||
export default function CreateIssueDialog(props: Props): ReactPortal {
|
||||
const dispatch = useDispatch();
|
||||
const { top, left } = props;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<WrappedMessageForm
|
||||
top={top}
|
||||
left={left}
|
||||
submit={(message: string) => {
|
||||
dispatch(finishIssueAsync(message));
|
||||
}}
|
||||
cancel={() => {
|
||||
dispatch(reviewActions.cancelIssue());
|
||||
}}
|
||||
/>,
|
||||
window.document.getElementById('cvat_canvas_attachment_board') as HTMLElement,
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React, { ReactPortal, useEffect } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import Tag from 'antd/lib/tag';
|
||||
import Icon from 'antd/lib/icon';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
message: string;
|
||||
top: number;
|
||||
left: number;
|
||||
resolved: boolean;
|
||||
onClick: () => void;
|
||||
highlight: () => void;
|
||||
blur: () => void;
|
||||
}
|
||||
|
||||
export default function HiddenIssueLabel(props: Props): ReactPortal {
|
||||
const {
|
||||
id, message, top, left, resolved, onClick, highlight, blur,
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolved) {
|
||||
setTimeout(highlight);
|
||||
} else {
|
||||
setTimeout(blur);
|
||||
}
|
||||
}, [resolved]);
|
||||
|
||||
const elementID = `cvat-hidden-issue-label-${id}`;
|
||||
return ReactDOM.createPortal(
|
||||
<Tooltip title={message}>
|
||||
<Tag
|
||||
id={elementID}
|
||||
onClick={onClick}
|
||||
onMouseEnter={highlight}
|
||||
onMouseLeave={blur}
|
||||
style={{ top, left }}
|
||||
className='cvat-hidden-issue-label'
|
||||
>
|
||||
{resolved ? (
|
||||
<Icon className='cvat-hidden-issue-resolved-indicator' type='check' />
|
||||
) : (
|
||||
<Icon className='cvat-hidden-issue-unsolved-indicator' type='close-circle' />
|
||||
)}
|
||||
{message}
|
||||
</Tag>
|
||||
</Tooltip>,
|
||||
window.document.getElementById('cvat_canvas_attachment_board') as HTMLElement,
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,143 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { Row, Col } from 'antd/lib/grid';
|
||||
import Comment from 'antd/lib/comment';
|
||||
import Text from 'antd/lib/typography/Text';
|
||||
import Title from 'antd/lib/typography/Title';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import Button from 'antd/lib/button';
|
||||
import Input from 'antd/lib/input';
|
||||
import Icon from 'antd/lib/icon';
|
||||
import moment from 'moment';
|
||||
|
||||
interface Props {
|
||||
id: number;
|
||||
comments: any[];
|
||||
left: number;
|
||||
top: number;
|
||||
resolved: boolean;
|
||||
isFetching: boolean;
|
||||
collapse: () => void;
|
||||
resolve: () => void;
|
||||
reopen: () => void;
|
||||
comment: (message: string) => void;
|
||||
highlight: () => void;
|
||||
blur: () => void;
|
||||
}
|
||||
|
||||
export default function IssueDialog(props: Props): JSX.Element {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [currentText, setCurrentText] = useState<string>('');
|
||||
const {
|
||||
comments,
|
||||
id,
|
||||
left,
|
||||
top,
|
||||
resolved,
|
||||
isFetching,
|
||||
collapse,
|
||||
resolve,
|
||||
reopen,
|
||||
comment,
|
||||
highlight,
|
||||
blur,
|
||||
} = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolved) {
|
||||
setTimeout(highlight);
|
||||
} else {
|
||||
setTimeout(blur);
|
||||
}
|
||||
}, [resolved]);
|
||||
|
||||
const lines = comments.map(
|
||||
(_comment: any): JSX.Element => {
|
||||
const created = _comment.createdDate ? moment(_comment.createdDate) : moment(moment.now());
|
||||
const diff = created.fromNow();
|
||||
|
||||
return (
|
||||
<Comment
|
||||
avatar={null}
|
||||
key={_comment.id}
|
||||
author={<Text strong>{_comment.author ? _comment.author.username : 'Unknown'}</Text>}
|
||||
content={<p>{_comment.message}</p>}
|
||||
datetime={(
|
||||
<Tooltip title={created.format('MMMM Do YYYY')}>
|
||||
<span>{diff}</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const resolveButton = resolved ? (
|
||||
<Button loading={isFetching} type='primary' onClick={reopen}>
|
||||
Reopen
|
||||
</Button>
|
||||
) : (
|
||||
<Button loading={isFetching} type='primary' onClick={resolve}>
|
||||
Resolve
|
||||
</Button>
|
||||
);
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div style={{ top, left }} ref={ref} className='cvat-issue-dialog'>
|
||||
<Row className='cvat-issue-dialog-header' type='flex' justify='space-between'>
|
||||
<Col>
|
||||
<Title level={4}>{id >= 0 ? `Issue #${id}` : 'Issue'}</Title>
|
||||
</Col>
|
||||
<Col>
|
||||
<Tooltip title='Collapse the chat'>
|
||||
<Icon type='close' onClick={collapse} />
|
||||
</Tooltip>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className='cvat-issue-dialog-chat' type='flex' justify='start'>
|
||||
<Col style={{ display: 'block' }}>{lines}</Col>
|
||||
</Row>
|
||||
<Row className='cvat-issue-dialog-input' type='flex' justify='start'>
|
||||
<Col span={24}>
|
||||
<Input
|
||||
placeholder='Print a comment here..'
|
||||
value={currentText}
|
||||
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCurrentText(event.target.value);
|
||||
}}
|
||||
onPressEnter={() => {
|
||||
if (currentText) {
|
||||
comment(currentText);
|
||||
setCurrentText('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row className='cvat-issue-dialog-footer' type='flex' justify='end'>
|
||||
<Col>
|
||||
{currentText.length ? (
|
||||
<Button
|
||||
loading={isFetching}
|
||||
type='primary'
|
||||
disabled={!currentText.length}
|
||||
onClick={() => {
|
||||
comment(currentText);
|
||||
setCurrentText('');
|
||||
}}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
) : (
|
||||
resolveButton
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</div>,
|
||||
window.document.getElementById('cvat_canvas_attachment_board') as HTMLElement,
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,167 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import './styles.scss';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
|
||||
import { CombinedState } from 'reducers/interfaces';
|
||||
import { Canvas } from 'cvat-canvas/src/typescript/canvas';
|
||||
|
||||
import { commentIssueAsync, resolveIssueAsync, reopenIssueAsync } from 'actions/review-actions';
|
||||
|
||||
import CreateIssueDialog from './create-issue-dialog';
|
||||
import HiddenIssueLabel from './hidden-issue-label';
|
||||
import IssueDialog from './issue-dialog';
|
||||
|
||||
const scaleHandler = (canvasInstance: Canvas): void => {
|
||||
const { geometry } = canvasInstance;
|
||||
const createDialogs = window.document.getElementsByClassName('cvat-create-issue-dialog');
|
||||
const hiddenIssues = window.document.getElementsByClassName('cvat-hidden-issue-label');
|
||||
const issues = window.document.getElementsByClassName('cvat-issue-dialog');
|
||||
for (const element of [...Array.from(createDialogs), ...Array.from(hiddenIssues), ...Array.from(issues)]) {
|
||||
(element as HTMLSpanElement).style.transform = `scale(${1 / geometry.scale}) rotate(${-geometry.angle}deg)`;
|
||||
}
|
||||
};
|
||||
|
||||
export default function IssueAggregatorComponent(): JSX.Element | null {
|
||||
const dispatch = useDispatch();
|
||||
const [expandedIssue, setExpandedIssue] = useState<number | null>(null);
|
||||
const frameIssues = useSelector((state: CombinedState): any[] => state.review.frameIssues);
|
||||
const canvasInstance = useSelector((state: CombinedState): Canvas => state.annotation.canvas.instance);
|
||||
const canvasIsReady = useSelector((state: CombinedState): boolean => state.annotation.canvas.ready);
|
||||
const newIssuePosition = useSelector((state: CombinedState): number[] | null => state.review.newIssuePosition);
|
||||
const issuesHidden = useSelector((state: CombinedState): any => state.review.issuesHidden);
|
||||
const issueFetching = useSelector((state: CombinedState): number | null => state.review.fetching.issueId);
|
||||
const issueLabels: JSX.Element[] = [];
|
||||
const issueDialogs: JSX.Element[] = [];
|
||||
|
||||
useEffect(() => {
|
||||
scaleHandler(canvasInstance);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const regions = frameIssues.reduce((acc: Record<number, number[]>, issue: any): Record<number, number[]> => {
|
||||
acc[issue.id] = issue.position;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
if (newIssuePosition) {
|
||||
regions[0] = newIssuePosition;
|
||||
}
|
||||
|
||||
canvasInstance.setupIssueRegions(regions);
|
||||
|
||||
if (newIssuePosition) {
|
||||
setExpandedIssue(null);
|
||||
const element = window.document.getElementById('cvat_canvas_issue_region_0');
|
||||
if (element) {
|
||||
element.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}, [newIssuePosition]);
|
||||
|
||||
useEffect(() => {
|
||||
const listener = (): void => scaleHandler(canvasInstance);
|
||||
|
||||
canvasInstance.html().addEventListener('canvas.zoom', listener);
|
||||
canvasInstance.html().addEventListener('canvas.fit', listener);
|
||||
|
||||
return () => {
|
||||
canvasInstance.html().removeEventListener('canvas.zoom', listener);
|
||||
canvasInstance.html().removeEventListener('canvas.fit', listener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!canvasIsReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { geometry } = canvasInstance;
|
||||
for (const issue of frameIssues) {
|
||||
if (issuesHidden) break;
|
||||
const issueResolved = !!issue.resolver;
|
||||
const offset = 15;
|
||||
const translated = issue.position.map((coord: number): number => coord + geometry.offset);
|
||||
const minX = Math.min(...translated.filter((_: number, idx: number): boolean => idx % 2 === 0)) + offset;
|
||||
const minY = Math.min(...translated.filter((_: number, idx: number): boolean => idx % 2 !== 0)) + offset;
|
||||
const { id } = issue;
|
||||
const highlight = (): void => {
|
||||
const element = window.document.getElementById(`cvat_canvas_issue_region_${id}`);
|
||||
if (element) {
|
||||
element.style.display = 'block';
|
||||
}
|
||||
};
|
||||
|
||||
const blur = (): void => {
|
||||
if (issueResolved) {
|
||||
const element = window.document.getElementById(`cvat_canvas_issue_region_${id}`);
|
||||
if (element) {
|
||||
element.style.display = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (expandedIssue === id) {
|
||||
issueDialogs.push(
|
||||
<IssueDialog
|
||||
key={issue.id}
|
||||
id={issue.id}
|
||||
top={minY}
|
||||
left={minX}
|
||||
isFetching={issueFetching !== null}
|
||||
comments={issue.comments}
|
||||
resolved={issueResolved}
|
||||
highlight={highlight}
|
||||
blur={blur}
|
||||
collapse={() => {
|
||||
setExpandedIssue(null);
|
||||
}}
|
||||
resolve={() => {
|
||||
dispatch(resolveIssueAsync(issue.id));
|
||||
setExpandedIssue(null);
|
||||
}}
|
||||
reopen={() => {
|
||||
dispatch(reopenIssueAsync(issue.id));
|
||||
}}
|
||||
comment={(message: string) => {
|
||||
dispatch(commentIssueAsync(issue.id, message));
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
} else if (issue.comments.length) {
|
||||
issueLabels.push(
|
||||
<HiddenIssueLabel
|
||||
key={issue.id}
|
||||
id={issue.id}
|
||||
top={minY}
|
||||
left={minX}
|
||||
resolved={issueResolved}
|
||||
message={issue.comments[issue.comments.length - 1].message}
|
||||
highlight={highlight}
|
||||
blur={blur}
|
||||
onClick={() => {
|
||||
setExpandedIssue(id);
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const translated = newIssuePosition ? newIssuePosition.map((coord: number): number => coord + geometry.offset) : [];
|
||||
const createLeft = translated.length ?
|
||||
Math.max(...translated.filter((_: number, idx: number): boolean => idx % 2 === 0)) :
|
||||
null;
|
||||
const createTop = translated.length ?
|
||||
Math.min(...translated.filter((_: number, idx: number): boolean => idx % 2 !== 0)) :
|
||||
null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{createLeft !== null && createTop !== null && <CreateIssueDialog top={createTop} left={createLeft} />}
|
||||
{issueDialogs}
|
||||
{issueLabels}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,112 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
@import 'base.scss';
|
||||
|
||||
.cvat-create-issue-dialog {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
width: $grid-unit-size * 30;
|
||||
padding: $grid-unit-size;
|
||||
background: $background-color-2;
|
||||
z-index: 100;
|
||||
transform-origin: top left;
|
||||
box-shadow: $box-shadow-base;
|
||||
|
||||
button {
|
||||
width: $grid-unit-size * 12;
|
||||
}
|
||||
}
|
||||
|
||||
.cvat-hidden-issue-label {
|
||||
position: absolute;
|
||||
min-width: 8 * $grid-unit-size;
|
||||
opacity: 0.8;
|
||||
z-index: 100;
|
||||
transition: none;
|
||||
pointer-events: auto;
|
||||
max-width: 16 * $grid-unit-size;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 0;
|
||||
transform-origin: top left;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cvat-issue-dialog {
|
||||
width: $grid-unit-size * 35;
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
transition: none;
|
||||
pointer-events: auto;
|
||||
background: $background-color-2;
|
||||
padding: $grid-unit-size;
|
||||
transform-origin: top left;
|
||||
box-shadow: $box-shadow-base;
|
||||
border-radius: 0.5 * $grid-unit-size;
|
||||
opacity: 0.95;
|
||||
|
||||
.cvat-issue-dialog-chat {
|
||||
> div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ant-comment {
|
||||
user-select: auto;
|
||||
padding: $grid-unit-size;
|
||||
padding-bottom: 0;
|
||||
|
||||
.ant-comment-content {
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.ant-comment-avatar {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
border-radius: 0.5 * $grid-unit-size;
|
||||
background: $background-color-1;
|
||||
padding: $grid-unit-size;
|
||||
max-height: $grid-unit-size * 45;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cvat-issue-dialog-input {
|
||||
background: $background-color-1;
|
||||
margin-top: $grid-unit-size;
|
||||
}
|
||||
|
||||
.cvat-issue-dialog-footer {
|
||||
margin-top: $grid-unit-size;
|
||||
}
|
||||
|
||||
.ant-comment > .ant-comment-inner {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.cvat-hidden-issue-indicator {
|
||||
margin-right: $grid-unit-size;
|
||||
}
|
||||
|
||||
.cvat-hidden-issue-resolved-indicator {
|
||||
@extend .cvat-hidden-issue-indicator;
|
||||
|
||||
color: $ok-icon-color;
|
||||
}
|
||||
|
||||
.cvat-hidden-issue-unsolved-indicator {
|
||||
@extend .cvat-hidden-issue-indicator;
|
||||
|
||||
color: $danger-icon-color;
|
||||
}
|
||||
@ -0,0 +1,149 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AnyAction } from 'redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import Text from 'antd/lib/typography/Text';
|
||||
import Title from 'antd/lib/typography/Title';
|
||||
import Modal from 'antd/lib/modal';
|
||||
import Radio, { RadioChangeEvent } from 'antd/lib/radio';
|
||||
import RadioButton from 'antd/lib/radio/radioButton';
|
||||
import Description from 'antd/lib/descriptions';
|
||||
import Rate from 'antd/lib/rate';
|
||||
import { Row, Col } from 'antd/lib/grid';
|
||||
|
||||
import UserSelector, { User } from 'components/task-page/user-selector';
|
||||
import { CombinedState, ReviewStatus } from 'reducers/interfaces';
|
||||
import { switchSubmitReviewDialog } from 'actions/annotation-actions';
|
||||
import { submitReviewAsync } from 'actions/review-actions';
|
||||
import { clamp } from 'utils/math';
|
||||
import { useHistory } from 'react-router';
|
||||
|
||||
function computeEstimatedQuality(reviewedStates: number, openedIssues: number): number {
|
||||
if (reviewedStates === 0 && openedIssues === 0) {
|
||||
return 5; // corner case
|
||||
}
|
||||
|
||||
const K = 2; // means how many reviewed states are equivalent to one issue
|
||||
const quality = reviewedStates / (reviewedStates + K * openedIssues);
|
||||
return clamp(+(5 * quality).toPrecision(2), 0, 5);
|
||||
}
|
||||
|
||||
export default function SubmitReviewModal(): JSX.Element | null {
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const isVisible = useSelector((state: CombinedState): boolean => state.annotation.submitReviewDialogVisible);
|
||||
const job = useSelector((state: CombinedState): any => state.annotation.job.instance);
|
||||
const activeReview = useSelector((state: CombinedState): any => state.review.activeReview);
|
||||
const reviewIsBeingSubmitted = useSelector((state: CombinedState): any => state.review.fetching.reviewId);
|
||||
const numberOfIssues = useSelector((state: CombinedState): any => state.review.issues.length);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const numberOfNewIssues = activeReview ? activeReview.issues.length : 0;
|
||||
const reviewedFrames = activeReview ? activeReview.reviewedFrames.length : 0;
|
||||
const reviewedStates = activeReview ? activeReview.reviewedStates.length : 0;
|
||||
|
||||
const [reviewer, setReviewer] = useState<User | null>(job.reviewer ? job.reviewer : null);
|
||||
const [reviewStatus, setReviewStatus] = useState<string>(ReviewStatus.ACCEPTED);
|
||||
const [estimatedQuality, setEstimatedQuality] = useState<number>(0);
|
||||
|
||||
const close = (): AnyAction => dispatch(switchSubmitReviewDialog(false));
|
||||
const submitReview = (): void => {
|
||||
activeReview.estimatedQuality = estimatedQuality;
|
||||
activeReview.status = reviewStatus;
|
||||
if (reviewStatus === ReviewStatus.REVIEW_FURTHER) {
|
||||
activeReview.reviewer = reviewer;
|
||||
}
|
||||
dispatch(submitReviewAsync(activeReview));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setEstimatedQuality(computeEstimatedQuality(reviewedStates, numberOfNewIssues));
|
||||
}, [reviewedStates, numberOfNewIssues]);
|
||||
useEffect(() => {
|
||||
if (!isSubmitting && activeReview && activeReview.id === reviewIsBeingSubmitted) {
|
||||
setIsSubmitting(true);
|
||||
} else if (isSubmitting && reviewIsBeingSubmitted === null) {
|
||||
setIsSubmitting(false);
|
||||
close();
|
||||
history.push(`/tasks/${job.task.id}`);
|
||||
}
|
||||
}, [reviewIsBeingSubmitted, activeReview]);
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className='cvat-submit-review-dialog'
|
||||
visible={isVisible}
|
||||
destroyOnClose
|
||||
confirmLoading={isSubmitting}
|
||||
onOk={submitReview}
|
||||
onCancel={close}
|
||||
okText='Submit'
|
||||
width={650}
|
||||
>
|
||||
<Row type='flex' justify='start'>
|
||||
<Col>
|
||||
<Title level={4}>Submitting your review</Title>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row type='flex' justify='start'>
|
||||
<Col span={12}>
|
||||
<Description title='Review summary' layout='horizontal' column={1} size='small' bordered>
|
||||
<Description.Item label='Estimated quality: '>{estimatedQuality}</Description.Item>
|
||||
<Description.Item label='Issues: '>
|
||||
<Text>{numberOfIssues}</Text>
|
||||
{!!numberOfNewIssues && <Text strong>{` (+${numberOfNewIssues})`}</Text>}
|
||||
</Description.Item>
|
||||
<Description.Item label='Reviewed frames '>{reviewedFrames}</Description.Item>
|
||||
<Description.Item label='Reviewed objects: '>{reviewedStates}</Description.Item>
|
||||
</Description>
|
||||
</Col>
|
||||
<Col span={11} offset={1}>
|
||||
<Row>
|
||||
<Col>
|
||||
<Radio.Group
|
||||
value={reviewStatus}
|
||||
onChange={(event: RadioChangeEvent) => {
|
||||
if (typeof event.target.value !== 'undefined') {
|
||||
setReviewStatus(event.target.value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RadioButton value={ReviewStatus.ACCEPTED}>Accept</RadioButton>
|
||||
<RadioButton value={ReviewStatus.REVIEW_FURTHER}>Review next</RadioButton>
|
||||
<RadioButton value={ReviewStatus.REJECTED}>Reject</RadioButton>
|
||||
</Radio.Group>
|
||||
{reviewStatus === ReviewStatus.REVIEW_FURTHER && (
|
||||
<Row align='middle' type='flex' justify='start'>
|
||||
<Col>
|
||||
<Text type='secondary'>Reviewer: </Text>
|
||||
</Col>
|
||||
<Col offset={1}>
|
||||
<UserSelector value={reviewer} onSelect={setReviewer} />
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
<Row type='flex' justify='center' align='middle'>
|
||||
<Col>
|
||||
<Rate
|
||||
value={Math.round(estimatedQuality)}
|
||||
onChange={(value: number | undefined) => {
|
||||
if (typeof value !== 'undefined') {
|
||||
setEstimatedQuality(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Col>
|
||||
</Row>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -1,36 +0,0 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
import ObjectItemContainer from 'containers/annotation-page/standard-workspace/objects-side-bar/object-item';
|
||||
|
||||
interface Props {
|
||||
activatedStateID: number | null;
|
||||
objectStates: any[];
|
||||
visible: boolean;
|
||||
left: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export default function CanvasContextMenu(props: Props): JSX.Element | null {
|
||||
const { activatedStateID, objectStates, visible, left, top } = props;
|
||||
|
||||
if (!visible || activatedStateID === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div className='cvat-canvas-context-menu' style={{ top, left }}>
|
||||
<ObjectItemContainer
|
||||
key={activatedStateID}
|
||||
clientID={activatedStateID}
|
||||
objectStates={objectStates}
|
||||
initialCollapsed
|
||||
/>
|
||||
</div>,
|
||||
window.document.body,
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { CombinedState } from 'reducers/interfaces';
|
||||
import Icon, { IconProps } from 'antd/lib/icon';
|
||||
import Tooltip from 'antd/lib/tooltip';
|
||||
import Alert from 'antd/lib/alert';
|
||||
import { Row, Col } from 'antd/lib/grid';
|
||||
|
||||
import { changeFrameAsync } from 'actions/annotation-actions';
|
||||
import { reviewActions } from 'actions/review-actions';
|
||||
|
||||
export default function LabelsListComponent(): JSX.Element {
|
||||
const dispatch = useDispatch();
|
||||
const tabContentHeight = useSelector((state: CombinedState) => state.annotation.tabContentHeight);
|
||||
const frame = useSelector((state: CombinedState): number => state.annotation.player.frame.number);
|
||||
const frameIssues = useSelector((state: CombinedState): any[] => state.review.frameIssues);
|
||||
const issues = useSelector((state: CombinedState): any[] => state.review.issues);
|
||||
const activeReview = useSelector((state: CombinedState): any => state.review.activeReview);
|
||||
const issuesHidden = useSelector((state: CombinedState): any => state.review.issuesHidden);
|
||||
const combinedIssues = activeReview ? issues.concat(activeReview.issues) : issues;
|
||||
const frames = combinedIssues.map((issue: any): number => issue.frame).sort((a: number, b: number) => +a - +b);
|
||||
const nearestLeft = frames.filter((_frame: number): boolean => _frame < frame).reverse()[0];
|
||||
const dinamicLeftProps: IconProps = Number.isInteger(nearestLeft) ?
|
||||
{
|
||||
onClick: () => dispatch(changeFrameAsync(nearestLeft)),
|
||||
} :
|
||||
{
|
||||
style: {
|
||||
pointerEvents: 'none',
|
||||
opacity: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
const nearestRight = frames.filter((_frame: number): boolean => _frame > frame)[0];
|
||||
const dinamicRightProps: IconProps = Number.isInteger(nearestRight) ?
|
||||
{
|
||||
onClick: () => dispatch(changeFrameAsync(nearestRight)),
|
||||
} :
|
||||
{
|
||||
style: {
|
||||
pointerEvents: 'none',
|
||||
opacity: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
const dinamicShowHideProps: IconProps = issuesHidden ?
|
||||
{
|
||||
onClick: () => dispatch(reviewActions.switchIssuesHiddenFlag(false)),
|
||||
type: 'eye-invisible',
|
||||
} :
|
||||
{
|
||||
onClick: () => dispatch(reviewActions.switchIssuesHiddenFlag(true)),
|
||||
type: 'eye',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ height: tabContentHeight }}>
|
||||
<div className='cvat-objects-sidebar-issues-list-header'>
|
||||
<Row type='flex' justify='start' align='middle'>
|
||||
<Col>
|
||||
<Tooltip title='Find the previous frame with issues'>
|
||||
<Icon type='left' {...dinamicLeftProps} />
|
||||
</Tooltip>
|
||||
</Col>
|
||||
<Col offset={1}>
|
||||
<Tooltip title='Find the next frame with issues'>
|
||||
<Icon type='right' {...dinamicRightProps} />
|
||||
</Tooltip>
|
||||
</Col>
|
||||
<Col offset={3}>
|
||||
<Tooltip title='Show/hide all the issues'>
|
||||
<Icon {...dinamicShowHideProps} />
|
||||
</Tooltip>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
<div className='cvat-objects-sidebar-issues-list'>
|
||||
{frameIssues.map(
|
||||
(frameIssue: any): JSX.Element => (
|
||||
<div
|
||||
className='cvat-objects-sidebar-issue-item'
|
||||
onMouseEnter={() => {
|
||||
const element = window.document.getElementById(
|
||||
`cvat_canvas_issue_region_${frameIssue.id}`,
|
||||
);
|
||||
if (element) {
|
||||
element.setAttribute('fill', 'url(#cvat_issue_region_pattern_2)');
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
const element = window.document.getElementById(
|
||||
`cvat_canvas_issue_region_${frameIssue.id}`,
|
||||
);
|
||||
if (element) {
|
||||
element.setAttribute('fill', 'url(#cvat_issue_region_pattern_1)');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{frameIssue.resolver ? (
|
||||
<Alert
|
||||
description={<span>{`By ${frameIssue.resolver.username}`}</span>}
|
||||
message='Resolved'
|
||||
type='success'
|
||||
showIcon
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
description={<span>{`By ${frameIssue.owner.username}`}</span>}
|
||||
message='Opened'
|
||||
type='warning'
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import React from 'react';
|
||||
import { Col } from 'antd/lib/grid';
|
||||
import Select from 'antd/lib/select';
|
||||
import Text from 'antd/lib/typography/Text';
|
||||
|
||||
import { StatesOrdering } from 'reducers/interfaces';
|
||||
|
||||
interface StatesOrderingSelectorComponentProps {
|
||||
statesOrdering: StatesOrdering;
|
||||
changeStatesOrdering(value: StatesOrdering): void;
|
||||
}
|
||||
|
||||
function StatesOrderingSelectorComponent(props: StatesOrderingSelectorComponentProps): JSX.Element {
|
||||
const { statesOrdering, changeStatesOrdering } = props;
|
||||
|
||||
return (
|
||||
<Col span={16}>
|
||||
<Text strong>Sort by</Text>
|
||||
<Select
|
||||
className='cvat-objects-sidebar-ordering-selector'
|
||||
value={statesOrdering}
|
||||
onChange={changeStatesOrdering}
|
||||
>
|
||||
<Select.Option key={StatesOrdering.ID_DESCENT} value={StatesOrdering.ID_DESCENT}>
|
||||
{StatesOrdering.ID_DESCENT}
|
||||
</Select.Option>
|
||||
<Select.Option key={StatesOrdering.ID_ASCENT} value={StatesOrdering.ID_ASCENT}>
|
||||
{StatesOrdering.ID_ASCENT}
|
||||
</Select.Option>
|
||||
<Select.Option key={StatesOrdering.UPDATED} value={StatesOrdering.UPDATED}>
|
||||
{StatesOrdering.UPDATED}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Col>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(StatesOrderingSelectorComponent);
|
||||
@ -0,0 +1,95 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import { ExtendedKeyMapOptions } from 'react-hotkeys';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { Canvas } from 'cvat-canvas-wrapper';
|
||||
import {
|
||||
selectIssuePosition as selectIssuePositionAction,
|
||||
mergeObjects,
|
||||
groupObjects,
|
||||
splitTrack,
|
||||
redrawShapeAsync,
|
||||
rotateCurrentFrame,
|
||||
repeatDrawShapeAsync,
|
||||
pasteShapeAsync,
|
||||
resetAnnotationsGroup,
|
||||
} from 'actions/annotation-actions';
|
||||
import ControlsSideBarComponent from 'components/annotation-page/review-workspace/controls-side-bar/controls-side-bar';
|
||||
import { ActiveControl, CombinedState, Rotation } from 'reducers/interfaces';
|
||||
|
||||
interface StateToProps {
|
||||
canvasInstance: Canvas;
|
||||
rotateAll: boolean;
|
||||
activeControl: ActiveControl;
|
||||
keyMap: Record<string, ExtendedKeyMapOptions>;
|
||||
normalizedKeyMap: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DispatchToProps {
|
||||
mergeObjects(enabled: boolean): void;
|
||||
groupObjects(enabled: boolean): void;
|
||||
splitTrack(enabled: boolean): void;
|
||||
rotateFrame(angle: Rotation): void;
|
||||
selectIssuePosition(enabled: boolean): void;
|
||||
resetGroup(): void;
|
||||
repeatDrawShape(): void;
|
||||
pasteShape(): void;
|
||||
redrawShape(): void;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: CombinedState): StateToProps {
|
||||
const {
|
||||
annotation: {
|
||||
canvas: { instance: canvasInstance, activeControl },
|
||||
},
|
||||
settings: {
|
||||
player: { rotateAll },
|
||||
},
|
||||
shortcuts: { keyMap, normalizedKeyMap },
|
||||
} = state;
|
||||
|
||||
return {
|
||||
rotateAll,
|
||||
canvasInstance,
|
||||
activeControl,
|
||||
normalizedKeyMap,
|
||||
keyMap,
|
||||
};
|
||||
}
|
||||
|
||||
function dispatchToProps(dispatch: any): DispatchToProps {
|
||||
return {
|
||||
mergeObjects(enabled: boolean): void {
|
||||
dispatch(mergeObjects(enabled));
|
||||
},
|
||||
groupObjects(enabled: boolean): void {
|
||||
dispatch(groupObjects(enabled));
|
||||
},
|
||||
splitTrack(enabled: boolean): void {
|
||||
dispatch(splitTrack(enabled));
|
||||
},
|
||||
selectIssuePosition(enabled: boolean): void {
|
||||
dispatch(selectIssuePositionAction(enabled));
|
||||
},
|
||||
rotateFrame(rotation: Rotation): void {
|
||||
dispatch(rotateCurrentFrame(rotation));
|
||||
},
|
||||
repeatDrawShape(): void {
|
||||
dispatch(repeatDrawShapeAsync());
|
||||
},
|
||||
pasteShape(): void {
|
||||
dispatch(pasteShapeAsync());
|
||||
},
|
||||
resetGroup(): void {
|
||||
dispatch(resetAnnotationsGroup());
|
||||
},
|
||||
redrawShape(): void {
|
||||
dispatch(redrawShapeAsync());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, dispatchToProps)(ControlsSideBarComponent);
|
||||
@ -0,0 +1,192 @@
|
||||
// Copyright (C) 2020 Intel Corporation
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
import consts from 'consts';
|
||||
import { AnnotationActionTypes } from 'actions/annotation-actions';
|
||||
import { ReviewActionTypes } from 'actions/review-actions';
|
||||
import { ReviewState } from './interfaces';
|
||||
|
||||
const defaultState: ReviewState = {
|
||||
reviews: [], // saved on the server
|
||||
issues: [], // saved on the server
|
||||
latestComments: [],
|
||||
frameIssues: [], // saved on the server and not saved on the server
|
||||
activeReview: null, // not saved on the server
|
||||
newIssuePosition: null,
|
||||
issuesHidden: false,
|
||||
fetching: {
|
||||
reviewId: null,
|
||||
issueId: null,
|
||||
},
|
||||
};
|
||||
|
||||
function computeFrameIssues(issues: any[], activeReview: any, frame: number): any[] {
|
||||
const combinedIssues = activeReview ? issues.concat(activeReview.issues) : issues;
|
||||
return combinedIssues.filter((issue: any): boolean => issue.frame === frame);
|
||||
}
|
||||
|
||||
export default function (state: ReviewState = defaultState, action: any): ReviewState {
|
||||
switch (action.type) {
|
||||
case AnnotationActionTypes.GET_JOB_SUCCESS: {
|
||||
const {
|
||||
reviews,
|
||||
issues,
|
||||
frameData: { number: frame },
|
||||
} = action.payload;
|
||||
const frameIssues = computeFrameIssues(issues, state.activeReview, frame);
|
||||
|
||||
return {
|
||||
...state,
|
||||
reviews,
|
||||
issues,
|
||||
frameIssues,
|
||||
};
|
||||
}
|
||||
case AnnotationActionTypes.CHANGE_FRAME: {
|
||||
return {
|
||||
...state,
|
||||
newIssuePosition: null,
|
||||
};
|
||||
}
|
||||
case ReviewActionTypes.SUBMIT_REVIEW: {
|
||||
const { reviewId } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
fetching: {
|
||||
...state.fetching,
|
||||
reviewId,
|
||||
},
|
||||
};
|
||||
}
|
||||
case ReviewActionTypes.SUBMIT_REVIEW_SUCCESS: {
|
||||
const {
|
||||
activeReview, reviews, issues, frame,
|
||||
} = action.payload;
|
||||
const frameIssues = computeFrameIssues(issues, activeReview, frame);
|
||||
|
||||
return {
|
||||
...state,
|
||||
activeReview,
|
||||
reviews,
|
||||
issues,
|
||||
frameIssues,
|
||||
fetching: {
|
||||
...state.fetching,
|
||||
reviewId: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
case ReviewActionTypes.SUBMIT_REVIEW_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
fetching: {
|
||||
...state.fetching,
|
||||
reviewId: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
case AnnotationActionTypes.CHANGE_FRAME_SUCCESS: {
|
||||
const { number: frame } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
frameIssues: computeFrameIssues(state.issues, state.activeReview, frame),
|
||||
};
|
||||
}
|
||||
case ReviewActionTypes.INITIALIZE_REVIEW_SUCCESS: {
|
||||
const { reviewInstance, frame } = action.payload;
|
||||
const frameIssues = computeFrameIssues(state.issues, reviewInstance, frame);
|
||||
|
||||
return {
|
||||
...state,
|
||||
activeReview: reviewInstance,
|
||||
frameIssues,
|
||||
};
|
||||
}
|
||||
case ReviewActionTypes.START_ISSUE: {
|
||||
const { position } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
newIssuePosition: position,
|
||||
};
|
||||
}
|
||||
case ReviewActionTypes.FINISH_ISSUE_SUCCESS: {
|
||||
const { frame, issue } = action.payload;
|
||||
const frameIssues = computeFrameIssues(state.issues, state.activeReview, frame);
|
||||
|
||||
return {
|
||||
...state,
|
||||
latestComments: state.latestComments.includes(issue.comments[0].message) ?
|
||||
state.latestComments :
|
||||
Array.from(
|
||||
new Set(
|
||||
[...state.latestComments, issue.comments[0].message].filter(
|
||||
(message: string): boolean =>
|
||||
![
|
||||
consts.QUICK_ISSUE_INCORRECT_POSITION_TEXT,
|
||||
consts.QUICK_ISSUE_INCORRECT_ATTRIBUTE_TEXT,
|
||||
].includes(message),
|
||||
),
|
||||
),
|
||||
).slice(-consts.LATEST_COMMENTS_SHOWN_QUICK_ISSUE),
|
||||
frameIssues,
|
||||
newIssuePosition: null,
|
||||
};
|
||||
}
|
||||
case ReviewActionTypes.CANCEL_ISSUE: {
|
||||
return {
|
||||
...state,
|
||||
newIssuePosition: null,
|
||||
};
|
||||
}
|
||||
case ReviewActionTypes.COMMENT_ISSUE:
|
||||
case ReviewActionTypes.RESOLVE_ISSUE:
|
||||
case ReviewActionTypes.REOPEN_ISSUE: {
|
||||
const { issueId } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
fetching: {
|
||||
...state.fetching,
|
||||
issueId,
|
||||
},
|
||||
};
|
||||
}
|
||||
case ReviewActionTypes.COMMENT_ISSUE_FAILED:
|
||||
case ReviewActionTypes.RESOLVE_ISSUE_FAILED:
|
||||
case ReviewActionTypes.REOPEN_ISSUE_FAILED: {
|
||||
return {
|
||||
...state,
|
||||
fetching: {
|
||||
...state.fetching,
|
||||
issueId: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
case ReviewActionTypes.RESOLVE_ISSUE_SUCCESS:
|
||||
case ReviewActionTypes.REOPEN_ISSUE_SUCCESS:
|
||||
case ReviewActionTypes.COMMENT_ISSUE_SUCCESS: {
|
||||
const { issues, frameIssues } = state;
|
||||
|
||||
return {
|
||||
...state,
|
||||
issues: [...issues],
|
||||
frameIssues: [...frameIssues],
|
||||
fetching: {
|
||||
...state.fetching,
|
||||
issueId: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
case ReviewActionTypes.SWITCH_ISSUES_HIDDEN_FLAG: {
|
||||
const { hidden } = action.payload;
|
||||
return {
|
||||
...state,
|
||||
issuesHidden: hidden,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
@ -0,0 +1,385 @@
|
||||
- [Mounting cloud storage](#mounting-cloud-storage)
|
||||
- [AWS S3 bucket](#aws-s3-bucket-as-filesystem)
|
||||
- [Ubuntu 20.04](#aws_s3_ubuntu_2004)
|
||||
- [Mount](#aws_s3_mount)
|
||||
- [Automatically mount](#aws_s3_automatically_mount)
|
||||
- [Using /etc/fstab](#aws_s3_using_fstab)
|
||||
- [Using systemd](#aws_s3_using_systemd)
|
||||
- [Check](#aws_s3_check)
|
||||
- [Unmount](#aws_s3_unmount_filesystem)
|
||||
- [Azure container](#microsoft-azure-container-as-filesystem)
|
||||
- [Ubuntu 20.04](#azure_ubuntu_2004)
|
||||
- [Mount](#azure_mount)
|
||||
- [Automatically mount](#azure_automatically_mount)
|
||||
- [Using /etc/fstab](#azure_using_fstab)
|
||||
- [Using systemd](#azure_using_systemd)
|
||||
- [Check](#azure_check)
|
||||
- [Unmount](#azure_unmount_filesystem)
|
||||
- [Google Drive](#google-drive-as-filesystem)
|
||||
- [Ubuntu 20.04](#google_drive_ubuntu_2004)
|
||||
- [Mount](#google_drive_mount)
|
||||
- [Automatically mount](#google_drive_automatically_mount)
|
||||
- [Using /etc/fstab](#google_drive_using_fstab)
|
||||
- [Using systemd](#google_drive_using_systemd)
|
||||
- [Check](#google_drive_check)
|
||||
- [Unmount](#google_drive_unmount_filesystem)
|
||||
|
||||
# Mounting cloud storage
|
||||
## AWS S3 bucket as filesystem
|
||||
### <a name="aws_s3_ubuntu_2004">Ubuntu 20.04</a>
|
||||
#### <a name="aws_s3_mount">Mount</a>
|
||||
|
||||
1. Install s3fs:
|
||||
|
||||
```bash
|
||||
sudo apt install s3fs
|
||||
```
|
||||
|
||||
1. Enter your credentials in a file `${HOME}/.passwd-s3fs` and set owner-only permissions:
|
||||
|
||||
```bash
|
||||
echo ACCESS_KEY_ID:SECRET_ACCESS_KEY > ${HOME}/.passwd-s3fs
|
||||
chmod 600 ${HOME}/.passwd-s3fs
|
||||
```
|
||||
|
||||
1. Uncomment `user_allow_other` in the `/etc/fuse.conf` file: `sudo nano /etc/fuse.conf`
|
||||
1. Run s3fs, replace `bucket_name`, `mount_point`:
|
||||
|
||||
```bash
|
||||
s3fs <bucket_name> <mount_point> -o allow_other
|
||||
```
|
||||
|
||||
For more details see [here](https://github.com/s3fs-fuse/s3fs-fuse).
|
||||
|
||||
#### <a name="aws_s3_automatically_mount">Automatically mount</a>
|
||||
Follow the first 3 mounting steps above.
|
||||
|
||||
##### <a name="aws_s3_using_fstab">Using fstab</a>
|
||||
|
||||
1. Create a bash script named aws_s3_fuse(e.g in /usr/bin, as root) with this content
|
||||
(replace `user_name` on whose behalf the disk will be mounted, `backet_name`, `mount_point`, `/path/to/.passwd-s3fs`):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
sudo -u <user_name> s3fs <backet_name> <mount_point> -o passwd_file=/path/to/.passwd-s3fs -o allow_other
|
||||
exit 0
|
||||
```
|
||||
|
||||
1. Give it the execution permission:
|
||||
|
||||
```bash
|
||||
sudo chmod +x /usr/bin/aws_s3_fuse
|
||||
```
|
||||
|
||||
1. Edit `/etc/fstab` adding a line like this, replace `mount_point`):
|
||||
|
||||
```bash
|
||||
/absolute/path/to/aws_s3_fuse <mount_point> fuse allow_other,user,_netdev 0 0
|
||||
```
|
||||
|
||||
##### <a name="aws_s3_using_systemd">Using systemd</a>
|
||||
|
||||
1. Create unit file `sudo nano /etc/systemd/system/s3fs.service`
|
||||
(replace `user_name`, `bucket_name`, `mount_point`, `/path/to/.passwd-s3fs`):
|
||||
|
||||
```bash
|
||||
[Unit]
|
||||
Description=FUSE filesystem over AWS S3 bucket
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Environment="MOUNT_POINT=<mount_point>"
|
||||
User=<user_name>
|
||||
Group=<user_name>
|
||||
ExecStart=s3fs <bucket_name> ${MOUNT_POINT} -o passwd_file=/path/to/.passwd-s3fs -o allow_other
|
||||
ExecStop=fusermount -u ${MOUNT_POINT}
|
||||
Restart=always
|
||||
Type=forking
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
1. Update the system configurations, enable unit autorun when the system boots, mount the bucket:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable s3fs.service
|
||||
sudo systemctl start s3fs.service
|
||||
```
|
||||
|
||||
#### <a name="aws_s3_check">Check</a>
|
||||
A file `/etc/mtab` contains records of currently mounted filesystems.
|
||||
```bash
|
||||
cat /etc/mtab | grep 's3fs'
|
||||
```
|
||||
|
||||
#### <a name="aws_s3_unmount_filesystem">Unmount filesystem</a>
|
||||
```bash
|
||||
fusermount -u <mount_point>
|
||||
```
|
||||
|
||||
If you used [systemd](#aws_s3_using_systemd) to mount a bucket:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop s3fs.service
|
||||
sudo systemctl disable s3fs.service
|
||||
```
|
||||
|
||||
## Microsoft Azure container as filesystem
|
||||
### <a name="azure_ubuntu_2004">Ubuntu 20.04</a>
|
||||
#### <a name="azure_mount">Mount</a>
|
||||
1. Set up the Microsoft package repository.(More [here](https://docs.microsoft.com/en-us/windows-server/administration/Linux-Package-Repository-for-Microsoft-Software#configuring-the-repositories))
|
||||
|
||||
```bash
|
||||
wget https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb
|
||||
sudo dpkg -i packages-microsoft-prod.deb
|
||||
sudo apt-get update
|
||||
```
|
||||
|
||||
1. Install `blobfuse` and `fuse`:
|
||||
|
||||
```bash
|
||||
sudo apt-get install blobfuse fuse
|
||||
```
|
||||
For more details see [here](https://github.com/Azure/azure-storage-fuse/wiki/1.-Installation)
|
||||
|
||||
1. Create enviroments(replace `account_name`, `account_key`, `mount_point`):
|
||||
|
||||
```bash
|
||||
export AZURE_STORAGE_ACCOUNT=<account_name>
|
||||
export AZURE_STORAGE_ACCESS_KEY=<account_key>
|
||||
MOUNT_POINT=<mount_point>
|
||||
```
|
||||
|
||||
1. Create a folder for cache:
|
||||
```bash
|
||||
sudo mkdir -p /mnt/blobfusetmp
|
||||
```
|
||||
|
||||
1. Make sure the file must be owned by the user who mounts the container:
|
||||
```bash
|
||||
sudo chown <user> /mnt/blobfusetmp
|
||||
```
|
||||
|
||||
1. Create the mount point, if it doesn't exists:
|
||||
```bash
|
||||
mkdir -p ${MOUNT_POINT}
|
||||
```
|
||||
|
||||
1. Uncomment `user_allow_other` in the `/etc/fuse.conf` file: `sudo nano /etc/fuse.conf`
|
||||
1. Mount container(replace `your_container`):
|
||||
|
||||
```bash
|
||||
blobfuse ${MOUNT_POINT} --container-name=<your_container> --tmp-path=/mnt/blobfusetmp -o allow_other
|
||||
```
|
||||
|
||||
#### <a name="azure_automatically_mount">Automatically mount</a>
|
||||
Follow the first 7 mounting steps above.
|
||||
##### <a name="azure_using_fstab">Using fstab</a>
|
||||
|
||||
1. Create configuration file `connection.cfg` with same content, change accountName,
|
||||
select one from accountKey or sasToken and replace with your value:
|
||||
|
||||
```bash
|
||||
accountName <account-name-here>
|
||||
# Please provide either an account key or a SAS token, and delete the other line.
|
||||
accountKey <account-key-here-delete-next-line>
|
||||
#change authType to specify only 1
|
||||
sasToken <shared-access-token-here-delete-previous-line>
|
||||
authType <MSI/SAS/SPN/Key/empty>
|
||||
containerName <insert-container-name-here>
|
||||
```
|
||||
|
||||
1. Create a bash script named `azure_fuse`(e.g in /usr/bin, as root) with content below
|
||||
(replace `user_name` on whose behalf the disk will be mounted, `mount_point`, `/path/to/blobfusetmp`,`/path/to/connection.cfg`):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
sudo -u <user_name> blobfuse <mount_point> --tmp-path=/path/to/blobfusetmp --config-file=/path/to/connection.cfg -o allow_other
|
||||
exit 0
|
||||
```
|
||||
|
||||
1. Give it the execution permission:
|
||||
```bash
|
||||
sudo chmod +x /usr/bin/azure_fuse
|
||||
```
|
||||
|
||||
1. Edit `/etc/fstab` with the blobfuse script. Add the following line(replace paths):
|
||||
```bash
|
||||
/absolute/path/to/azure_fuse </path/to/desired/mountpoint> fuse allow_other,user,_netdev
|
||||
```
|
||||
|
||||
##### <a name="azure_using_systemd">Using systemd</a>
|
||||
|
||||
1. Create unit file `sudo nano /etc/systemd/system/blobfuse.service`.
|
||||
(replace `user_name`, `mount_point`, `container_name`,`/path/to/connection.cfg`):
|
||||
|
||||
```bash
|
||||
[Unit]
|
||||
Description=FUSE filesystem over Azure container
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Environment="MOUNT_POINT=<mount_point>"
|
||||
User=<user_name>
|
||||
Group=<user_name>
|
||||
ExecStart=blobfuse ${MOUNT_POINT} --container-name=<container_name> --tmp-path=/mnt/blobfusetmp --config-file=/path/to/connection.cfg -o allow_other
|
||||
ExecStop=fusermount -u ${MOUNT_POINT}
|
||||
Restart=always
|
||||
Type=forking
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
1. Update the system configurations, enable unit autorun when the system boots, mount the container:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable blobfuse.service
|
||||
sudo systemctl start blobfuse.service
|
||||
```
|
||||
Or for more detail [see here](https://github.com/Azure/azure-storage-fuse/tree/master/systemd)
|
||||
|
||||
#### <a name="azure_check">Check</a>
|
||||
A file `/etc/mtab` contains records of currently mounted filesystems.
|
||||
```bash
|
||||
cat /etc/mtab | grep 'blobfuse'
|
||||
```
|
||||
|
||||
#### <a name="azure_unmount_filesystem">Unmount filesystem</a>
|
||||
```bash
|
||||
fusermount -u <mount_point>
|
||||
```
|
||||
|
||||
If you used [systemd](#azure_using_systemd) to mount a container:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop blobfuse.service
|
||||
sudo systemctl disable blobfuse.service
|
||||
```
|
||||
|
||||
If you have any mounting problems, check out the [answers](https://github.com/Azure/azure-storage-fuse/wiki/3.-Troubleshoot-FAQ)
|
||||
to common problems
|
||||
|
||||
## Google Drive as filesystem
|
||||
### <a name="google_drive_ubuntu_2004">Ubuntu 20.04</a>
|
||||
#### <a name="google_drive_mount">Mount</a>
|
||||
To mount a google drive as a filesystem in user space(FUSE)
|
||||
you can use [google-drive-ocamlfuse](https://github.com/astrada/google-drive-ocamlfuse)
|
||||
To do this follow the instructions below:
|
||||
|
||||
1. Install google-drive-ocamlfuse:
|
||||
|
||||
```bash
|
||||
sudo add-apt-repository ppa:alessandro-strada/ppa
|
||||
sudo apt-get update
|
||||
sudo apt-get install google-drive-ocamlfuse
|
||||
```
|
||||
|
||||
1. Run `google-drive-ocamlfuse` without parameters:
|
||||
|
||||
```bash
|
||||
google-drive-ocamlfuse
|
||||
```
|
||||
|
||||
This command will create the default application directory (~/.gdfuse/default),
|
||||
containing the configuration file config (see the [wiki](https://github.com/astrada/google-drive-ocamlfuse/wiki)
|
||||
page for more details about configuration).
|
||||
And it will start a web browser to obtain authorization to access your Google Drive.
|
||||
This will let you modify default configuration before mounting the filesystem.
|
||||
|
||||
Then you can choose a local directory to mount your Google Drive (e.g.: ~/GoogleDrive).
|
||||
|
||||
1. Create the mount point, if it doesn't exist(replace mount_point):
|
||||
|
||||
```bash
|
||||
mountpoint="<mount_point>"
|
||||
mkdir -p $mountpoint
|
||||
```
|
||||
|
||||
1. Uncomment `user_allow_other` in the `/etc/fuse.conf` file: `sudo nano /etc/fuse.conf`
|
||||
1. Mount the filesystem:
|
||||
|
||||
```bash
|
||||
google-drive-ocamlfuse -o allow_other $mountpoint
|
||||
```
|
||||
|
||||
#### <a name="google_drive_automatically_mount">Automatically mount</a>
|
||||
Follow the first 4 mounting steps above.
|
||||
##### <a name="google_drive_using_fstab">Using fstab</a>
|
||||
|
||||
1. Create a bash script named gdfuse(e.g in /usr/bin, as root) with this content
|
||||
(replace `user_name` on whose behalf the disk will be mounted, `label`, `mount_point`):
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
sudo -u <user_name> google-drive-ocamlfuse -o allow_other -label <label> <mount_point>
|
||||
exit 0
|
||||
```
|
||||
|
||||
1. Give it the execution permission:
|
||||
|
||||
```bash
|
||||
sudo chmod +x /usr/bin/gdfuse
|
||||
```
|
||||
|
||||
1. Edit `/etc/fstab` adding a line like this, replace `mount_point`):
|
||||
|
||||
```bash
|
||||
/absolute/path/to/gdfuse <mount_point> fuse allow_other,user,_netdev 0 0
|
||||
```
|
||||
|
||||
For more details see [here](https://github.com/astrada/google-drive-ocamlfuse/wiki/Automounting)
|
||||
|
||||
##### <a name="google_drive_using_systemd">Using systemd</a>
|
||||
|
||||
1. Create unit file `sudo nano /etc/systemd/system/google-drive-ocamlfuse.service`.
|
||||
(replace `user_name`, `label`(default `label=default`), `mount_point`):
|
||||
|
||||
```bash
|
||||
[Unit]
|
||||
Description=FUSE filesystem over Google Drive
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Environment="MOUNT_POINT=<mount_point>"
|
||||
User=<user_name>
|
||||
Group=<user_name>
|
||||
ExecStart=google-drive-ocamlfuse -label <label> ${MOUNT_POINT}
|
||||
ExecStop=fusermount -u ${MOUNT_POINT}
|
||||
Restart=always
|
||||
Type=forking
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
1. Update the system configurations, enable unit autorun when the system boots, mount the drive:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable google-drive-ocamlfuse.service
|
||||
sudo systemctl start google-drive-ocamlfuse.service
|
||||
```
|
||||
|
||||
For more details see [here](https://github.com/astrada/google-drive-ocamlfuse/wiki/Automounting)
|
||||
|
||||
#### <a name="google_drive_check">Check</a>
|
||||
A file `/etc/mtab` contains records of currently mounted filesystems.
|
||||
```bash
|
||||
cat /etc/mtab | grep 'google-drive-ocamlfuse'
|
||||
```
|
||||
|
||||
#### <a name="google_drive_unmount_filesystem">Unmount filesystem</a>
|
||||
```bash
|
||||
fusermount -u <mount_point>
|
||||
```
|
||||
|
||||
If you used [systemd](#google_drive_using_systemd) to mount a drive:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop google-drive-ocamlfuse.service
|
||||
sudo systemctl disable google-drive-ocamlfuse.service
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue