diff --git a/.travis.yml b/.travis.yml index 68533c0da491..9acef6b67834 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,6 +11,7 @@ cache: - ~/.cache addons: + firefox: "latest" apt: packages: - libgconf-2-4 @@ -43,7 +44,8 @@ script: - docker exec -it cvat bash -ic "echo \"from django.contrib.auth.models import User; User.objects.create_superuser('${DJANGO_SU_NAME}', '${DJANGO_SU_EMAIL}', '${DJANGO_SU_PASSWORD}')\" | python3 ~/manage.py shell" # Install Cypress and run tests - cd ./tests && npm install - - $(npm bin)/cypress run --headless --browser chrome && cd .. + - $(npm bin)/cypress run --headless --browser chrome + - $(npm bin)/cypress run --headless --browser firefox && cd .. after_success: # https://coveralls-python.readthedocs.io/en/latest/usage/multilang.html diff --git a/CHANGELOG.md b/CHANGELOG.md index f707e0cd77d0..a033a92ea26c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,29 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.1.0] - Unreleased +## [1.2.0] - Unreleased +### Added +- Added password reset functionality () +- Ability to work with data on the fly (https://github.com/opencv/cvat/pull/2007) +- Annotation in process outline color wheel () + +### Changed +- UI models (like DEXTR) were redesigned to be more interactive () +- Used Ubuntu:20.04 as a base image for CVAT Dockerfile () + +### Deprecated +- + +### Removed +- + +### Fixed +- Fixed multiple errors which arises when polygon is of length 5 or less () + +### Security +- + +## [1.1.0] - 2020-08-31 ### Added - Siammask tracker as DL serverless function () - [Datumaro] Added model info and source info commands () @@ -19,22 +41,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Shape coordinates are rounded to 2 digits in dumped annotations () - COCO format does not produce polygon points for bbox annotations () -### Deprecated -- - -### Removed -- - ### Fixed - Issue loading openvino models for semi-automatic and automatic annotation () - Basic functions of CVAT works without activated nuclio dashboard - Fixed a case in which exported masks could have wrong color order () - Fixed error with creating task with labels with the same name () - Django RQ dashboard view () -- Fixed multiple errors which arises when polygon is of length 5 or less () - -### Security -- +- Object's details menu settings () ## [1.1.0-beta] - 2020-08-03 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 08c3cfd08a7b..afa51684a0b1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,25 +14,14 @@ Next steps should work on clear Ubuntu 18.04. - Install necessary dependencies: ```sh - sudo apt-get update && sudo apt-get --no-install-recommends install -y ffmpeg build-essential curl redis-server python3-dev python3-pip python3-venv python3-tk libldap2-dev libsasl2-dev + sudo apt-get update && sudo apt-get --no-install-recommends install -y build-essential curl redis-server python3-dev python3-pip python3-venv python3-tk libldap2-dev libsasl2-dev pkg-config libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev libswscale-dev libswresample-dev libavfilter-dev ``` - Also please make sure that you have installed ffmpeg with all necessary libav* libraries and pkg-config package. + Please make sure you have installed FFmpeg libraries (libav*) version 4.0 or higher. ```sh # Node and npm (you can use default versions of these packages from apt (8.*, 3.*), but we would recommend to use newer versions) curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash - sudo apt-get install -y nodejs - # General dependencies - sudo apt-get install -y pkg-config - - # Library components - sudo apt-get install -y \ - libavformat-dev libavcodec-dev libavdevice-dev \ - libavutil-dev libswscale-dev libswresample-dev libavfilter-dev - ``` - See [PyAV Dependencies installation guide](http://docs.mikeboers.com/pyav/develop/overview/installation.html#dependencies) - for details. - - Install [Visual Studio Code](https://code.visualstudio.com/docs/setup/linux#_debian-and-ubuntu-based-distributions) for development diff --git a/Dockerfile b/Dockerfile index e27dde2892ac..18fb7f7574c6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:16.04 +FROM ubuntu:20.04 ARG http_proxy ARG https_proxy @@ -23,8 +23,6 @@ ENV DJANGO_CONFIGURATION=${DJANGO_CONFIGURATION} RUN apt-get update && \ apt-get --no-install-recommends install -yq \ software-properties-common && \ - add-apt-repository ppa:mc3man/xerus-media -y && \ - add-apt-repository ppa:mc3man/gstffmpeg-keep -y && \ apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends install -yq \ apache2 \ @@ -33,15 +31,13 @@ RUN apt-get update && \ build-essential \ libapache2-mod-xsendfile \ supervisor \ - ffmpeg \ - gstreamer0.10-ffmpeg \ - libavcodec-dev \ - libavdevice-dev \ - libavfilter-dev \ - libavformat-dev \ - libavutil-dev \ - libswresample-dev \ - libswscale-dev \ + libavcodec-dev=7:4.2.4-1ubuntu0.1 \ + libavdevice-dev=7:4.2.4-1ubuntu0.1 \ + libavfilter-dev=7:4.2.4-1ubuntu0.1 \ + libavformat-dev=7:4.2.4-1ubuntu0.1 \ + libavutil-dev=7:4.2.4-1ubuntu0.1 \ + libswresample-dev=7:4.2.4-1ubuntu0.1 \ + libswscale-dev=7:4.2.4-1ubuntu0.1 \ libldap2-dev \ libsasl2-dev \ pkg-config \ @@ -50,16 +46,13 @@ RUN apt-get update && \ tzdata \ p7zip-full \ git \ + git-lfs \ ssh \ poppler-utils \ curl && \ - curl https://packagecloud.io/install/repositories/github/git-lfs/script.deb.sh | bash && \ - apt-get --no-install-recommends install -y git-lfs && git lfs install && \ - python3 -m pip install --no-cache-dir -U pip==20.0.1 setuptools>=49.1.0 wheel==0.35.1 && \ + python3 -m pip install --no-cache-dir -U pip==20.0.1 setuptools==49.6.0 wheel==0.35.1 && \ ln -fs /usr/share/zoneinfo/${TZ} /etc/localtime && \ dpkg-reconfigure -f noninteractive tzdata && \ - add-apt-repository --remove ppa:mc3man/gstffmpeg-keep -y && \ - add-apt-repository --remove ppa:mc3man/xerus-media -y && \ rm -rf /var/lib/apt/lists/* && \ echo 'application/wasm wasm' >> /etc/mime.types diff --git a/Dockerfile.ci b/Dockerfile.ci index 422259de8a83..f65cf36ca8d7 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -3,17 +3,20 @@ FROM cvat/server ENV DJANGO_CONFIGURATION=testing USER root -RUN curl https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \ - echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | tee /etc/apt/sources.list.d/google-chrome.list && \ - curl https://deb.nodesource.com/setup_12.x | bash - && \ - apt-get update && \ +RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends install -yq \ + gpg-agent \ apt-utils \ build-essential \ + python3-dev \ + ruby \ + && \ + curl https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \ + echo 'deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main' | tee /etc/apt/sources.list.d/google-chrome.list && \ + curl https://deb.nodesource.com/setup_12.x | bash - && \ + DEBIAN_FRONTEND=noninteractive apt-get --no-install-recommends install -yq \ google-chrome-stable \ nodejs \ - python3-dev \ - ruby \ && \ rm -rf /var/lib/apt/lists/*; diff --git a/cvat-canvas/README.md b/cvat-canvas/README.md index 808aa5ac52d0..d6a5698bae41 100644 --- a/cvat-canvas/README.md +++ b/cvat-canvas/README.md @@ -46,6 +46,7 @@ Canvas itself handles: IDLE = 'idle', DRAG = 'drag', RESIZE = 'resize', + INTERACT = 'interact', DRAW = 'draw', EDIT = 'edit', MERGE = 'merge', @@ -70,6 +71,11 @@ Canvas itself handles: crosshair?: boolean; } + interface InteractionData { + shapeType: string; + minVertices?: number; + } + interface GroupData { enabled: boolean; resetGroup?: boolean; @@ -83,6 +89,12 @@ Canvas itself handles: enabled: boolean; } + interface InteractionResult { + points: number[]; + shapeType: string; + button: number; + }; + interface DrawnData { shapeType: string; points: number[]; @@ -104,6 +116,7 @@ Canvas itself handles: grid(stepX: number, stepY: number): void; draw(drawData: DrawData): void; + interact(interactionData: InteractionData): void; group(groupData: GroupData): void; split(splitData: SplitData): void; merge(mergeData: MergeData): void; @@ -146,6 +159,7 @@ Standard JS events are used. - canvas.moved => {states: ObjectState[], x: number, y: number} - canvas.find => {states: ObjectState[], x: number, y: number} - canvas.drawn => {state: DrawnData} + - canvas.interacted => {shapes: InteractionResult[]} - canvas.editstart - canvas.edited => {state: ObjectState, points: number[]} - canvas.splitted => {state: ObjectState} @@ -187,25 +201,26 @@ Standard JS events are used. ## API Reaction -| | IDLE | GROUP | SPLIT | DRAW | MERGE | EDIT | DRAG | RESIZE | ZOOM_CANVAS | DRAG_CANVAS | -|--------------|------|-------|-------|------|-------|------|------|--------|-------------|-------------| -| setup() | + | + | + | +/- | + | +/- | +/- | +/- | + | + | -| activate() | + | - | - | - | - | - | - | - | - | - | -| rotate() | + | + | + | + | + | + | + | + | + | + | -| focus() | + | + | + | + | + | + | + | + | + | + | -| fit() | + | + | + | + | + | + | + | + | + | + | -| grid() | + | + | + | + | + | + | + | + | + | + | -| draw() | + | - | - | - | - | - | - | - | - | - | -| split() | + | - | + | - | - | - | - | - | - | - | -| group() | + | + | - | - | - | - | - | - | - | - | -| merge() | + | - | - | - | + | - | - | - | - | - | -| fitCanvas() | + | + | + | + | + | + | + | + | + | + | -| dragCanvas() | + | - | - | - | - | - | + | - | - | + | -| zoomCanvas() | + | - | - | - | - | - | - | + | + | - | -| cancel() | - | + | + | + | + | + | + | + | + | + | -| configure() | + | + | + | + | + | + | + | + | + | + | -| bitmap() | + | + | + | + | + | + | + | + | + | + | -| setZLayer() | + | + | + | + | + | + | + | + | + | + | +| | IDLE | GROUP | SPLIT | DRAW | MERGE | EDIT | DRAG | RESIZE | ZOOM_CANVAS | DRAG_CANVAS | INTERACT | +|--------------|------|-------|-------|------|-------|------|------|--------|-------------|-------------|----------| +| setup() | + | + | + | +/- | + | +/- | +/- | +/- | + | + | + | +| activate() | + | - | - | - | - | - | - | - | - | - | - | +| rotate() | + | + | + | + | + | + | + | + | + | + | + | +| focus() | + | + | + | + | + | + | + | + | + | + | + | +| fit() | + | + | + | + | + | + | + | + | + | + | + | +| grid() | + | + | + | + | + | + | + | + | + | + | + | +| draw() | + | - | - | + | - | - | - | - | - | - | - | +| interact() | + | - | - | - | - | - | - | - | - | - | + | +| split() | + | - | + | - | - | - | - | - | - | - | - | +| group() | + | + | - | - | - | - | - | - | - | - | - | +| merge() | + | - | - | - | + | - | - | - | - | - | - | +| fitCanvas() | + | + | + | + | + | + | + | + | + | + | + | +| dragCanvas() | + | - | - | - | - | - | + | - | - | + | - | +| zoomCanvas() | + | - | - | - | - | - | - | + | + | - | - | +| cancel() | - | + | + | + | + | + | + | + | + | + | + | +| configure() | + | + | + | + | + | + | + | + | + | + | + | +| bitmap() | + | + | + | + | + | + | + | + | + | + | + | +| setZLayer() | + | + | + | + | + | + | + | + | + | + | + | You can call setup() during editing, dragging, and resizing only to update objects, not to change a frame. You can change frame during draw only when you do not redraw an existing object diff --git a/cvat-canvas/package-lock.json b/cvat-canvas/package-lock.json index b50b6223aa66..5ee1996827da 100644 --- a/cvat-canvas/package-lock.json +++ b/cvat-canvas/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "2.0.2", + "version": "2.1.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/cvat-canvas/package.json b/cvat-canvas/package.json index 8792ce080236..ba7f0180f584 100644 --- a/cvat-canvas/package.json +++ b/cvat-canvas/package.json @@ -1,6 +1,6 @@ { "name": "cvat-canvas", - "version": "2.0.2", + "version": "2.1.0", "description": "Part of Computer Vision Annotation Tool which presents its canvas library", "main": "src/canvas.ts", "scripts": { diff --git a/cvat-canvas/src/typescript/canvas.ts b/cvat-canvas/src/typescript/canvas.ts index 356997666f7d..508e55648eee 100644 --- a/cvat-canvas/src/typescript/canvas.ts +++ b/cvat-canvas/src/typescript/canvas.ts @@ -8,26 +8,17 @@ import { MergeData, SplitData, GroupData, + InteractionData, + InteractionResult, CanvasModel, CanvasModelImpl, RectDrawingMethod, CuboidDrawingMethod, Configuration, } from './canvasModel'; - -import { - Master, -} from './master'; - -import { - CanvasController, - CanvasControllerImpl, -} from './canvasController'; - -import { - CanvasView, - CanvasViewImpl, -} from './canvasView'; +import { Master } from './master'; +import { CanvasController, CanvasControllerImpl } from './canvasController'; +import { CanvasView, CanvasViewImpl } from './canvasView'; import '../scss/canvas.scss'; import pjson from '../../package.json'; @@ -43,6 +34,7 @@ interface Canvas { fit(): void; grid(stepX: number, stepY: number): void; + interact(interactionData: InteractionData): void; draw(drawData: DrawData): void; group(groupData: GroupData): void; split(splitData: SplitData): void; @@ -118,6 +110,10 @@ class CanvasImpl implements Canvas { this.model.grid(stepX, stepY); } + public interact(interactionData: InteractionData): void { + this.model.interact(interactionData); + } + public draw(drawData: DrawData): void { this.model.draw(drawData); } @@ -162,4 +158,6 @@ export { RectDrawingMethod, CuboidDrawingMethod, Mode as CanvasMode, + InteractionData, + InteractionResult, }; diff --git a/cvat-canvas/src/typescript/canvasController.ts b/cvat-canvas/src/typescript/canvasController.ts index 179f9b32e869..786836d8b0cb 100644 --- a/cvat-canvas/src/typescript/canvasController.ts +++ b/cvat-canvas/src/typescript/canvasController.ts @@ -13,6 +13,7 @@ import { SplitData, GroupData, Mode, + InteractionData, } from './canvasModel'; export interface CanvasController { @@ -21,6 +22,7 @@ export interface CanvasController { readonly focusData: FocusData; readonly activeElement: ActiveElement; readonly drawData: DrawData; + readonly interactionData: InteractionData; readonly mergeData: MergeData; readonly splitData: SplitData; readonly groupData: GroupData; @@ -30,6 +32,7 @@ export interface CanvasController { zoom(x: number, y: number, direction: number): void; draw(drawData: DrawData): void; + interact(interactionData: InteractionData): void; merge(mergeData: MergeData): void; split(splitData: SplitData): void; group(groupData: GroupData): void; @@ -84,6 +87,10 @@ export class CanvasControllerImpl implements CanvasController { this.model.draw(drawData); } + public interact(interactionData: InteractionData): void { + this.model.interact(interactionData); + } + public merge(mergeData: MergeData): void { this.model.merge(mergeData); } @@ -124,6 +131,10 @@ export class CanvasControllerImpl implements CanvasController { return this.model.drawData; } + public get interactionData(): InteractionData { + return this.model.interactionData; + } + public get mergeData(): MergeData { return this.model.mergeData; } diff --git a/cvat-canvas/src/typescript/canvasModel.ts b/cvat-canvas/src/typescript/canvasModel.ts index ec860e77bbe5..74d9f49cea8f 100644 --- a/cvat-canvas/src/typescript/canvasModel.ts +++ b/cvat-canvas/src/typescript/canvasModel.ts @@ -69,6 +69,20 @@ export interface DrawData { redraw?: number; } +export interface InteractionData { + enabled: boolean; + shapeType?: string; + crosshair?: boolean; + minPosVertices?: number; + minNegVertices?: number; +} + +export interface InteractionResult { + points: number[]; + shapeType: string; + button: number; +} + export interface EditData { enabled: boolean; state: any; @@ -105,6 +119,7 @@ export enum UpdateReasons { FITTED_CANVAS = 'fitted_canvas', + INTERACT = 'interact', DRAW = 'draw', MERGE = 'merge', SPLIT = 'split', @@ -126,6 +141,7 @@ export enum Mode { MERGE = 'merge', SPLIT = 'split', GROUP = 'group', + INTERACT = 'interact', DRAG_CANVAS = 'drag_canvas', ZOOM_CANVAS = 'zoom_canvas', } @@ -139,6 +155,7 @@ export interface CanvasModel { readonly focusData: FocusData; readonly activeElement: ActiveElement; readonly drawData: DrawData; + readonly interactionData: InteractionData; readonly mergeData: MergeData; readonly splitData: SplitData; readonly groupData: GroupData; @@ -162,6 +179,7 @@ export interface CanvasModel { split(splitData: SplitData): void; merge(mergeData: MergeData): void; select(objectState: any): void; + interact(interactionData: InteractionData): void; fitCanvas(width: number, height: number): void; bitmap(enabled: boolean): void; @@ -192,6 +210,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { top: number; zLayer: number | null; drawData: DrawData; + interactionData: InteractionData; mergeData: MergeData; groupData: GroupData; splitData: SplitData; @@ -242,6 +261,9 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { enabled: false, initialState: null, }, + interactionData: { + enabled: false, + }, mergeData: { enabled: false, }, @@ -490,6 +512,27 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { this.notify(UpdateReasons.DRAW); } + public interact(interactionData: InteractionData): void { + if (![Mode.IDLE, Mode.INTERACT].includes(this.data.mode)) { + throw Error(`Canvas is busy. Action: ${this.data.mode}`); + } + + if (interactionData.enabled) { + if (this.data.interactionData.enabled) { + throw new Error('Interaction has been already started'); + } else if (!interactionData.shapeType) { + throw new Error('A shape type was not specified'); + } + } + + this.data.interactionData = interactionData; + if (typeof (this.data.interactionData.crosshair) !== 'boolean') { + this.data.interactionData.crosshair = true; + } + + this.notify(UpdateReasons.INTERACT); + } + public split(splitData: SplitData): void { if (![Mode.IDLE, Mode.SPLIT].includes(this.data.mode)) { throw Error(`Canvas is busy. Action: ${this.data.mode}`); @@ -567,7 +610,7 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { } public isAbleToChangeFrame(): boolean { - const isUnable = [Mode.DRAG, Mode.EDIT, Mode.RESIZE].includes(this.data.mode) + const isUnable = [Mode.DRAG, Mode.EDIT, Mode.RESIZE, Mode.INTERACT].includes(this.data.mode) || (this.data.mode === Mode.DRAW && typeof (this.data.drawData.redraw) === 'number'); return !isUnable; @@ -647,6 +690,10 @@ export class CanvasModelImpl extends MasterImpl implements CanvasModel { return { ...this.data.drawData }; } + public get interactionData(): InteractionData { + return { ...this.data.interactionData }; + } + public get mergeData(): MergeData { return { ...this.data.mergeData }; } diff --git a/cvat-canvas/src/typescript/canvasView.ts b/cvat-canvas/src/typescript/canvasView.ts index 4eb95a8fc3e0..3cf37f045a85 100644 --- a/cvat-canvas/src/typescript/canvasView.ts +++ b/cvat-canvas/src/typescript/canvasView.ts @@ -16,6 +16,7 @@ import { MergeHandler, MergeHandlerImpl } from './mergeHandler'; import { SplitHandler, SplitHandlerImpl } from './splitHandler'; import { GroupHandler, GroupHandlerImpl } from './groupHandler'; import { ZoomHandler, ZoomHandlerImpl } from './zoomHandler'; +import { InteractionHandler, InteractionHandlerImpl } from './interactionHandler'; import { AutoborderHandler, AutoborderHandlerImpl } from './autoborderHandler'; import consts from './consts'; import { @@ -42,6 +43,8 @@ import { Mode, Size, Configuration, + InteractionResult, + InteractionData, } from './canvasModel'; export interface CanvasView { @@ -72,6 +75,7 @@ export class CanvasViewImpl implements CanvasView, Listener { private groupHandler: GroupHandler; private zoomHandler: ZoomHandler; private autoborderHandler: AutoborderHandler; + private interactionHandler: InteractionHandler; private activeElement: ActiveElement; private configuration: Configuration; private serviceFlags: { @@ -127,6 +131,41 @@ export class CanvasViewImpl implements CanvasView, Listener { } } + private onInteraction( + shapes: InteractionResult[] | null, + shapesUpdated: boolean = true, + isDone: boolean = false, + ): void { + const { zLayer } = this.controller; + if (Array.isArray(shapes)) { + const event: CustomEvent = new CustomEvent('canvas.interacted', { + bubbles: false, + cancelable: true, + detail: { + shapesUpdated, + isDone, + shapes, + zOrder: zLayer || 0, + }, + }); + + this.canvas.dispatchEvent(event); + } + + if (shapes === null || isDone) { + const event: CustomEvent = new CustomEvent('canvas.canceled', { + bubbles: false, + cancelable: true, + }); + + this.canvas.dispatchEvent(event); + this.mode = Mode.IDLE; + this.controller.interact({ + enabled: false, + }); + } + } + private onDrawDone(data: object | null, duration: number, continueDraw?: boolean): void { const hiddenBecauseOfDraw = Object.keys(this.serviceFlags.drawHidden) .map((_clientID): number => +_clientID); @@ -373,6 +412,8 @@ export class CanvasViewImpl implements CanvasView, Listener { this.drawHandler.transform(this.geometry); this.editHandler.transform(this.geometry); this.zoomHandler.transform(this.geometry); + this.autoborderHandler.transform(this.geometry); + this.interactionHandler.transform(this.geometry); } private transformCanvas(): void { @@ -438,7 +479,9 @@ export class CanvasViewImpl implements CanvasView, Listener { // Transform handlers this.drawHandler.transform(this.geometry); this.editHandler.transform(this.geometry); + this.zoomHandler.transform(this.geometry); this.autoborderHandler.transform(this.geometry); + this.interactionHandler.transform(this.geometry); } private resizeCanvas(): void { @@ -846,6 +889,11 @@ export class CanvasViewImpl implements CanvasView, Listener { this.adoptedContent, this.geometry, ); + this.interactionHandler = new InteractionHandlerImpl( + this.onInteraction.bind(this), + this.adoptedContent, + this.geometry, + ); // Setup event handlers this.content.addEventListener('dblclick', (e: MouseEvent): void => { @@ -1063,6 +1111,18 @@ export class CanvasViewImpl implements CanvasView, Listener { this.drawHandler.draw(data, this.geometry); } } + } else if (reason === UpdateReasons.INTERACT) { + const data: InteractionData = this.controller.interactionData; + if (data.enabled && this.mode === Mode.IDLE) { + this.canvas.style.cursor = 'crosshair'; + this.mode = Mode.INTERACT; + this.interactionHandler.interact(data); + } else { + this.canvas.style.cursor = ''; + if (this.mode !== Mode.IDLE) { + this.interactionHandler.interact(data); + } + } } else if (reason === UpdateReasons.MERGE) { const data: MergeData = this.controller.mergeData; if (data.enabled) { @@ -1101,6 +1161,8 @@ export class CanvasViewImpl implements CanvasView, Listener { } else if (reason === UpdateReasons.CANCEL) { if (this.mode === Mode.DRAW) { this.drawHandler.cancel(); + } else if (this.mode === Mode.INTERACT) { + this.interactionHandler.cancel(); } else if (this.mode === Mode.MERGE) { this.mergeHandler.cancel(); } else if (this.mode === Mode.SPLIT) { @@ -1405,6 +1467,11 @@ export class CanvasViewImpl implements CanvasView, Listener { [state, +state.getAttribute('data-z-order')] )); + const crosshair = Array.from(this.content.getElementsByClassName('cvat_canvas_crosshair')); + crosshair.forEach((line: SVGLineElement): void => this.content.append(line)); + const interaction = Array.from(this.content.getElementsByClassName('cvat_interaction_point')); + interaction.forEach((circle: SVGCircleElement): void => this.content.append(circle)); + const needSort = states.some((pair): boolean => pair[1] !== states[0][1]); if (!states.length || !needSort) { return; diff --git a/cvat-canvas/src/typescript/crosshair.ts b/cvat-canvas/src/typescript/crosshair.ts new file mode 100644 index 000000000000..27d255698ac8 --- /dev/null +++ b/cvat-canvas/src/typescript/crosshair.ts @@ -0,0 +1,70 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import * as SVG from 'svg.js'; +import consts from './consts'; + +export default class Crosshair { + private x: SVG.Line | null; + private y: SVG.Line | null; + private canvas: SVG.Container | null; + + public constructor() { + this.x = null; + this.y = null; + this.canvas = null; + } + + public show(canvas: SVG.Container, x: number, y: number, scale: number): void { + if (this.canvas && this.canvas !== canvas) { + if (this.x) this.x.remove(); + if (this.y) this.y.remove(); + this.x = null; + this.y = null; + } + + this.canvas = canvas; + this.x = this.canvas.line(0, y, this.canvas.node.clientWidth, y).attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * scale), + }).addClass('cvat_canvas_crosshair'); + + this.y = this.canvas.line(x, 0, x, this.canvas.node.clientHeight).attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * scale), + }).addClass('cvat_canvas_crosshair'); + } + + public hide(): void { + if (this.x) { + this.x.remove(); + this.x = null; + } + + if (this.y) { + this.y.remove(); + this.y = null; + } + + this.canvas = null; + } + + public move(x: number, y: number): void { + if (this.x) { + this.x.attr({ y1: y, y2: y }); + } + + if (this.y) { + this.y.attr({ x1: x, x2: x }); + } + } + + public scale(scale: number): void { + if (this.x) { + this.x.attr('stroke-width', consts.BASE_STROKE_WIDTH / (2 * scale)); + } + + if (this.y) { + this.y.attr('stroke-width', consts.BASE_STROKE_WIDTH / (2 * scale)); + } + } +} diff --git a/cvat-canvas/src/typescript/drawHandler.ts b/cvat-canvas/src/typescript/drawHandler.ts index 404157bd6110..6716ead0622a 100644 --- a/cvat-canvas/src/typescript/drawHandler.ts +++ b/cvat-canvas/src/typescript/drawHandler.ts @@ -16,6 +16,7 @@ import { BBox, Box, } from './shared'; +import Crosshair from './crosshair'; import consts from './consts'; import { DrawData, @@ -44,10 +45,7 @@ export class DrawHandlerImpl implements DrawHandler { x: number; y: number; }; - private crosshair: { - x: SVG.Line; - y: SVG.Line; - }; + private crosshair: Crosshair; private drawData: DrawData; private geometry: Geometry; private autoborderHandler: AutoborderHandler; @@ -188,22 +186,11 @@ export class DrawHandlerImpl implements DrawHandler { private addCrosshair(): void { const { x, y } = this.cursorPosition; - this.crosshair = { - x: this.canvas.line(0, y, this.canvas.node.clientWidth, y).attr({ - 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * this.geometry.scale), - zOrder: Number.MAX_SAFE_INTEGER, - }).addClass('cvat_canvas_crosshair'), - y: this.canvas.line(x, 0, x, this.canvas.node.clientHeight).attr({ - 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * this.geometry.scale), - zOrder: Number.MAX_SAFE_INTEGER, - }).addClass('cvat_canvas_crosshair'), - }; + this.crosshair.show(this.canvas, x, y, this.geometry.scale); } private removeCrosshair(): void { - this.crosshair.x.remove(); - this.crosshair.y.remove(); - this.crosshair = null; + this.crosshair.hide(); } private release(): void { @@ -741,7 +728,7 @@ export class DrawHandlerImpl implements DrawHandler { this.canceled = false; this.drawData = null; this.geometry = null; - this.crosshair = null; + this.crosshair = new Crosshair(); this.drawInstance = null; this.pointsGroup = null; this.cursorPosition = { @@ -756,8 +743,7 @@ export class DrawHandlerImpl implements DrawHandler { ); this.cursorPosition = { x, y }; if (this.crosshair) { - this.crosshair.x.attr({ y1: y, y2: y }); - this.crosshair.y.attr({ x1: x, x2: x }); + this.crosshair.move(x, y); } }); } @@ -787,12 +773,7 @@ export class DrawHandlerImpl implements DrawHandler { } if (this.crosshair) { - this.crosshair.x.attr({ - 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * geometry.scale), - }); - this.crosshair.y.attr({ - 'stroke-width': consts.BASE_STROKE_WIDTH / (2 * geometry.scale), - }); + this.crosshair.scale(this.geometry.scale); } if (this.pointsGroup) { diff --git a/cvat-canvas/src/typescript/editHandler.ts b/cvat-canvas/src/typescript/editHandler.ts index 8cea079d148b..413beda73665 100644 --- a/cvat-canvas/src/typescript/editHandler.ts +++ b/cvat-canvas/src/typescript/editHandler.ts @@ -115,7 +115,7 @@ export class EditHandlerImpl implements EditHandler { (this.editLine as any).addClass('cvat_canvas_shape_drawing').style({ 'pointer-events': 'none', 'fill-opacity': 0, - 'stroke': strokeColor, + stroke: strokeColor, }).attr({ 'data-origin-client-id': this.editData.state.clientID, }).on('drawstart drawpoint', (e: CustomEvent): void => { @@ -213,20 +213,20 @@ export class EditHandlerImpl implements EditHandler { const cutIndexes2 = oldPoints.reduce((acc: string[], _: string, i: number) => i <= stop && i >= start ? [...acc, i] : acc, []); - const curveLength = (indexes: number[]) => { + const curveLength = (indexes: number[]): number => { const points = indexes.map((index: number): string => oldPoints[index]) .map((point: string): string[] => point.split(',')) .map((point: string[]): number[] => [+point[0], +point[1]]); let length = 0; for (let i = 1; i < points.length; i++) { length += Math.sqrt( - (points[i][0] - points[i - 1][0]) ** 2 - + (points[i][1] - points[i - 1][1]) ** 2, + ((points[i][0] - points[i - 1][0]) ** 2) + + ((points[i][1] - points[i - 1][1]) ** 2), ); } return length; - } + }; const pointsCriteria = cutIndexes1.length > cutIndexes2.length; const lengthCriteria = curveLength(cutIndexes1) > curveLength(cutIndexes2); @@ -278,8 +278,6 @@ export class EditHandlerImpl implements EditHandler { }); } } - - return; } private setupPoints(enabled: boolean): void { diff --git a/cvat-canvas/src/typescript/interactionHandler.ts b/cvat-canvas/src/typescript/interactionHandler.ts new file mode 100644 index 000000000000..76237cf06ed4 --- /dev/null +++ b/cvat-canvas/src/typescript/interactionHandler.ts @@ -0,0 +1,281 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import * as SVG from 'svg.js'; +import consts from './consts'; +import Crosshair from './crosshair'; +import { translateToSVG } from './shared'; +import { InteractionData, InteractionResult, Geometry } from './canvasModel'; + +export interface InteractionHandler { + transform(geometry: Geometry): void; + interact(interactData: InteractionData): void; + cancel(): void; +} + +export class InteractionHandlerImpl implements InteractionHandler { + private onInteraction: ( + shapes: InteractionResult[] | null, + shapesUpdated?: boolean, + isDone?: boolean, + ) => void; + private geometry: Geometry; + private canvas: SVG.Container; + private interactionData: InteractionData; + private cursorPosition: { x: number; y: number }; + private shapesWereUpdated: boolean; + private interactionShapes: SVG.Shape[]; + private currentInteractionShape: SVG.Shape | null; + private crosshair: Crosshair; + + private prepareResult(): InteractionResult[] { + return this.interactionShapes.map((shape: SVG.Shape): InteractionResult => { + if (shape.type === 'circle') { + const points = [(shape as SVG.Circle).cx(), (shape as SVG.Circle).cy()]; + return { + points: points.map((coord: number): number => coord - this.geometry.offset), + shapeType: 'points', + button: shape.attr('stroke') === 'green' ? 0 : 2, + }; + } + + const bbox = (shape.node as any as SVGRectElement).getBBox(); + const points = [bbox.x, bbox.y, bbox.x + bbox.width, bbox.y + bbox.height]; + return { + points: points.map((coord: number): number => coord - this.geometry.offset), + shapeType: 'rectangle', + button: 0, + }; + }); + } + + private shouldRaiseEvent(ctrlKey: boolean): boolean { + const { interactionData, interactionShapes, shapesWereUpdated } = this; + const { minPosVertices, minNegVertices, enabled } = interactionData; + + const positiveShapes = interactionShapes + .filter((shape: SVG.Shape): boolean => (shape as any).attr('stroke') === 'green'); + const negativeShapes = interactionShapes + .filter((shape: SVG.Shape): boolean => (shape as any).attr('stroke') !== 'green'); + + if (interactionData.shapeType === 'rectangle') { + return enabled && !ctrlKey && !!interactionShapes.length; + } + + const minimumVerticesAchieved = (typeof (minPosVertices) === 'undefined' + || minPosVertices <= positiveShapes.length) && (typeof (minNegVertices) === 'undefined' + || minPosVertices <= negativeShapes.length); + return enabled && !ctrlKey && minimumVerticesAchieved && shapesWereUpdated; + } + + private addCrosshair(): void { + const { x, y } = this.cursorPosition; + this.crosshair.show(this.canvas, x, y, this.geometry.scale); + } + + private removeCrosshair(): void { + this.crosshair.hide(); + } + + private interactPoints(): void { + const eventListener = (e: MouseEvent): void => { + if ((e.button === 0 || e.button === 2) && !e.altKey) { + e.preventDefault(); + const [cx, cy] = translateToSVG( + this.canvas.node as any as SVGSVGElement, + [e.clientX, e.clientY], + ); + this.currentInteractionShape = this.canvas + .circle(consts.BASE_POINT_SIZE * 2 / this.geometry.scale).center(cx, cy) + .fill('white') + .stroke(e.button === 0 ? 'green' : 'red') + .addClass('cvat_interaction_point') + .attr({ + 'stroke-width': consts.POINTS_STROKE_WIDTH / this.geometry.scale, + }); + + this.interactionShapes.push(this.currentInteractionShape); + this.shapesWereUpdated = true; + if (this.shouldRaiseEvent(e.ctrlKey)) { + this.onInteraction(this.prepareResult(), true, false); + } + + const self = this.currentInteractionShape; + self.on('mouseenter', (): void => { + self.attr({ + 'stroke-width': consts.POINTS_SELECTED_STROKE_WIDTH / this.geometry.scale, + }); + + self.on('mousedown', (_e: MouseEvent): void => { + _e.preventDefault(); + _e.stopPropagation(); + self.remove(); + this.interactionShapes = this.interactionShapes.filter( + (shape: SVG.Shape): boolean => shape !== self, + ); + this.shapesWereUpdated = true; + if (this.shouldRaiseEvent(_e.ctrlKey)) { + this.onInteraction(this.prepareResult(), true, false); + } + }); + }); + + self.on('mouseleave', (): void => { + self.attr({ + 'stroke-width': consts.POINTS_STROKE_WIDTH / this.geometry.scale, + }); + + self.off('mousedown'); + }); + } + }; + + // clear this listener in relese() + this.canvas.on('mousedown.interaction', eventListener); + } + + private interactRectangle(): void { + let initialized = false; + const eventListener = (e: MouseEvent): void => { + if (e.button === 0 && !e.altKey) { + if (!initialized) { + (this.currentInteractionShape as any).draw(e, { snapToGrid: 0.1 }); + initialized = true; + } else { + (this.currentInteractionShape as any).draw(e); + } + } + }; + + this.currentInteractionShape = this.canvas.rect(); + this.canvas.on('mousedown.interaction', eventListener); + this.currentInteractionShape.on('drawstop', (): void => { + this.interactionShapes.push(this.currentInteractionShape); + this.shapesWereUpdated = true; + + this.canvas.off('mousedown.interaction', eventListener); + if (this.shouldRaiseEvent(false)) { + this.onInteraction(this.prepareResult(), true, false); + } + + this.interact({ enabled: false }); + }).addClass('cvat_canvas_shape_drawing').attr({ + 'stroke-width': consts.BASE_STROKE_WIDTH / this.geometry.scale, + }); + } + + private initInteraction(): void { + if (this.interactionData.crosshair) { + this.addCrosshair(); + } + } + + private startInteraction(): void { + if (this.interactionData.shapeType === 'rectangle') { + this.interactRectangle(); + } else if (this.interactionData.shapeType === 'points') { + this.interactPoints(); + } else { + throw new Error('Interactor implementation supports only rectangle and points'); + } + } + + private release(): void { + if (this.crosshair) { + this.removeCrosshair(); + } + + this.canvas.off('mousedown.interaction'); + this.interactionShapes.forEach((shape: SVG.Shape): SVG.Shape => shape.remove()); + this.interactionShapes = []; + if (this.currentInteractionShape) { + this.currentInteractionShape.remove(); + this.currentInteractionShape = null; + } + } + + public constructor( + onInteraction: ( + shapes: InteractionResult[] | null, + shapesUpdated?: boolean, + isDone?: boolean, + ) => void, + canvas: SVG.Container, + geometry: Geometry, + ) { + this.onInteraction = ( + shapes: InteractionResult[] | null, + shapesUpdated?: boolean, + isDone?: boolean, + ): void => { + this.shapesWereUpdated = false; + onInteraction(shapes, shapesUpdated, isDone); + }; + this.canvas = canvas; + this.geometry = geometry; + this.shapesWereUpdated = false; + this.interactionShapes = []; + this.interactionData = { enabled: false }; + this.currentInteractionShape = null; + this.crosshair = new Crosshair(); + this.cursorPosition = { + x: 0, + y: 0, + }; + + this.canvas.on('mousemove.interaction', (e: MouseEvent): void => { + const [x, y] = translateToSVG( + this.canvas.node as any as SVGSVGElement, + [e.clientX, e.clientY], + ); + this.cursorPosition = { x, y }; + if (this.crosshair) { + this.crosshair.move(x, y); + } + }); + + document.body.addEventListener('keyup', (e: KeyboardEvent): void => { + if (e.keyCode === 17 && this.shouldRaiseEvent(false)) { // 17 is ctrl + this.onInteraction(this.prepareResult(), true, false); + } + }); + } + + public transform(geometry: Geometry): void { + this.geometry = geometry; + + if (this.crosshair) { + this.crosshair.scale(this.geometry.scale); + } + + const shapesToBeScaled = this.currentInteractionShape + ? [...this.interactionShapes, this.currentInteractionShape] + : [...this.interactionShapes]; + for (const shape of shapesToBeScaled) { + if (shape.type === 'circle') { + (shape as SVG.Circle).radius(consts.BASE_POINT_SIZE / this.geometry.scale); + shape.attr('stroke-width', consts.POINTS_STROKE_WIDTH / this.geometry.scale); + } else { + shape.attr('stroke-width', consts.BASE_STROKE_WIDTH / this.geometry.scale); + } + } + } + + public interact(interactionData: InteractionData): void { + if (interactionData.enabled) { + this.interactionData = interactionData; + this.initInteraction(); + this.startInteraction(); + } else { + this.onInteraction(this.prepareResult(), this.shouldRaiseEvent(false), true); + this.release(); + this.interactionData = interactionData; + } + } + + public cancel(): void { + this.release(); + this.onInteraction(null); + } +} diff --git a/cvat-core/package-lock.json b/cvat-core/package-lock.json index 0bc2c78700cf..91ac8a0f8221 100644 --- a/cvat-core/package-lock.json +++ b/cvat-core/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "3.5.0", + "version": "3.6.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/cvat-core/package.json b/cvat-core/package.json index ca65f045f7d4..4418f1574495 100644 --- a/cvat-core/package.json +++ b/cvat-core/package.json @@ -1,6 +1,6 @@ { "name": "cvat-core", - "version": "3.5.0", + "version": "3.6.0", "description": "Part of Computer Vision Tool which presents an interface for client-side integration", "main": "babel.config.js", "scripts": { diff --git a/cvat-core/src/annotations-history.js b/cvat-core/src/annotations-history.js index 4fdbf34c193f..ee84fddc36ba 100644 --- a/cvat-core/src/annotations-history.js +++ b/cvat-core/src/annotations-history.js @@ -7,9 +7,14 @@ const MAX_HISTORY_LENGTH = 128; class AnnotationHistory { constructor() { + this.frozen = false; this.clear(); } + freeze(frozen) { + this.frozen = frozen; + } + get() { return { undo: this._undo.map((undo) => [undo.action, undo.frame]), @@ -18,6 +23,7 @@ class AnnotationHistory { } do(action, undo, redo, clientIDs, frame) { + if (this.frozen) return; const actionItem = { clientIDs, action, diff --git a/cvat-core/src/annotations.js b/cvat-core/src/annotations.js index 9d7eadbf6d0d..63a316ace065 100644 --- a/cvat-core/src/annotations.js +++ b/cvat-core/src/annotations.js @@ -327,6 +327,19 @@ ); } + function freezeHistory(session, frozen) { + const sessionType = session instanceof Task ? 'task' : 'job'; + const cache = getCache(sessionType); + + if (cache.has(session)) { + return cache.get(session).history.freeze(frozen); + } + + throw new DataError( + 'Collection has not been initialized yet. Call annotations.get() or annotations.clear(true) before', + ); + } + function clearActions(session) { const sessionType = session instanceof Task ? 'task' : 'job'; const cache = getCache(sessionType); @@ -372,6 +385,7 @@ exportDataset, undoActions, redoActions, + freezeHistory, clearActions, getActions, closeSession, diff --git a/cvat-core/src/api-implementation.js b/cvat-core/src/api-implementation.js index 5fc7958c28f9..39c0d9112cb0 100644 --- a/cvat-core/src/api-implementation.js +++ b/cvat-core/src/api-implementation.js @@ -99,6 +99,14 @@ await serverProxy.server.changePassword(oldPassword, newPassword1, newPassword2); }; + cvat.server.requestPasswordReset.implementation = async (email) => { + await serverProxy.server.requestPasswordReset(email); + }; + + cvat.server.resetPassword.implementation = async(newPassword1, newPassword2, uid, token) => { + await serverProxy.server.resetPassword(newPassword1, newPassword2, uid, token); + }; + cvat.server.authorized.implementation = async () => { const result = await serverProxy.server.authorized(); return result; diff --git a/cvat-core/src/api.js b/cvat-core/src/api.js index 9d040683bcc6..1b70911839b2 100644 --- a/cvat-core/src/api.js +++ b/cvat-core/src/api.js @@ -199,6 +199,9 @@ function build() { * @method changePassword * @async * @memberof module:API.cvat.server + * @param {string} oldPassword Current password for the account + * @param {string} newPassword1 New password for the account + * @param {string} newPassword2 Confirmation password for the account * @throws {module:API.cvat.exceptions.PluginError} * @throws {module:API.cvat.exceptions.ServerError} */ @@ -207,6 +210,38 @@ function build() { .apiWrapper(cvat.server.changePassword, oldPassword, newPassword1, newPassword2); return result; }, + /** + * Method allows to reset user password + * @method requestPasswordReset + * @async + * @memberof module:API.cvat.server + * @param {string} email A email address for the account + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + */ + async requestPasswordReset(email) { + const result = await PluginRegistry + .apiWrapper(cvat.server.requestPasswordReset, email); + return result; + }, + /** + * Method allows to confirm reset user password + * @method resetPassword + * @async + * @memberof module:API.cvat.server + * @param {string} newPassword1 New password for the account + * @param {string} newPassword2 Confirmation password for the account + * @param {string} uid User id + * @param {string} token Request authentication token + * @throws {module:API.cvat.exceptions.PluginError} + * @throws {module:API.cvat.exceptions.ServerError} + */ + async resetPassword(newPassword1, newPassword2, uid, token) { + const result = await PluginRegistry + .apiWrapper(cvat.server.resetPassword, newPassword1, newPassword2, + uid, token); + return result; + }, /** * Method allows to know whether you are authorized on the server * @method authorized diff --git a/cvat-core/src/server-proxy.js b/cvat-core/src/server-proxy.js index 4a0c9ebd8b69..58724d35c092 100644 --- a/cvat-core/src/server-proxy.js +++ b/cvat-core/src/server-proxy.js @@ -264,6 +264,41 @@ } } + async function requestPasswordReset(email) { + try { + const data = JSON.stringify({ + email, + }); + await Axios.post(`${config.backendAPI}/auth/password/reset`, data, { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + } + + async function resetPassword(newPassword1, newPassword2, uid, token) { + try { + const data = JSON.stringify({ + new_password1: newPassword1, + new_password2: newPassword2, + uid, + token, + }); + await Axios.post(`${config.backendAPI}/auth/password/reset/confirm`, data, { + proxy: config.proxy, + headers: { + 'Content-Type': 'application/json', + }, + }); + } catch (errorData) { + throw generateError(errorData); + } + } + async function authorized() { try { await module.exports.users.getSelf(); @@ -787,6 +822,8 @@ login, logout, changePassword, + requestPasswordReset, + resetPassword, authorized, register, request: serverRequest, diff --git a/cvat-core/src/session.js b/cvat-core/src/session.js index 6a994e0a88c6..8d0287258718 100644 --- a/cvat-core/src/session.js +++ b/cvat-core/src/session.js @@ -170,6 +170,11 @@ .apiWrapper.call(this, prototype.actions.redo, count); return result; }, + async freeze(frozen) { + const result = await PluginRegistry + .apiWrapper.call(this, prototype.actions.freeze, frozen); + return result; + }, async clear() { const result = await PluginRegistry .apiWrapper.call(this, prototype.actions.clear); @@ -545,6 +550,14 @@ * @instance * @async */ + /** + * Freeze history (do not save new actions) + * @method freeze + * @memberof Session.actions + * @throws {module:API.cvat.exceptions.PluginError} + * @instance + * @async + */ /** * Remove all actions from history * @method clear @@ -745,6 +758,7 @@ this.actions = { undo: Object.getPrototypeOf(this).actions.undo.bind(this), redo: Object.getPrototypeOf(this).actions.redo.bind(this), + freeze: Object.getPrototypeOf(this).actions.freeze.bind(this), clear: Object.getPrototypeOf(this).actions.clear.bind(this), get: Object.getPrototypeOf(this).actions.get.bind(this), }; @@ -819,6 +833,7 @@ data_compressed_chunk_type: undefined, data_original_chunk_type: undefined, use_zip_chunks: undefined, + use_cache: undefined, }; for (const property in data) { @@ -1074,6 +1089,24 @@ data.use_zip_chunks = useZipChunks; }, }, + /** + * @name useCache + * @type {boolean} + * @memberof module:API.cvat.classes.Task + * @instance + * @throws {module:API.cvat.exceptions.ArgumentError} + */ + useCache: { + get: () => data.use_cache, + set: (useCache) => { + if (typeof (useCache) !== 'boolean') { + throw new ArgumentError( + 'Value must be a boolean', + ); + } + data.use_cache = useCache; + }, + }, /** * After task has been created value can be appended only. * @name labels @@ -1299,6 +1332,7 @@ this.actions = { undo: Object.getPrototypeOf(this).actions.undo.bind(this), redo: Object.getPrototypeOf(this).actions.redo.bind(this), + freeze: Object.getPrototypeOf(this).actions.freeze.bind(this), clear: Object.getPrototypeOf(this).actions.clear.bind(this), get: Object.getPrototypeOf(this).actions.get.bind(this), }; @@ -1390,6 +1424,7 @@ exportDataset, undoActions, redoActions, + freezeHistory, clearActions, getActions, closeSession, @@ -1582,6 +1617,11 @@ return result; }; + Job.prototype.actions.freeze.implementation = function (frozen) { + const result = freezeHistory(this, frozen); + return result; + }; + Job.prototype.actions.clear.implementation = function () { const result = clearActions(this); return result; @@ -1645,6 +1685,7 @@ remote_files: this.remoteFiles, image_quality: this.imageQuality, use_zip_chunks: this.useZipChunks, + use_cache: this.useCache, }; if (typeof (this.startFrame) !== 'undefined') { @@ -1846,6 +1887,11 @@ return result; }; + Task.prototype.actions.freeze.implementation = function (frozen) { + const result = freezeHistory(this, frozen); + return result; + }; + Task.prototype.actions.clear.implementation = function () { const result = clearActions(this); return result; diff --git a/cvat-ui/package-lock.json b/cvat-ui/package-lock.json index fbbd3cea5674..7370e0fa5462 100644 --- a/cvat-ui/package-lock.json +++ b/cvat-ui/package-lock.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.8.4", + "version": "1.9.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/cvat-ui/package.json b/cvat-ui/package.json index 1065c3f82efd..66ba9e864eed 100644 --- a/cvat-ui/package.json +++ b/cvat-ui/package.json @@ -1,6 +1,6 @@ { "name": "cvat-ui", - "version": "1.8.4", + "version": "1.9.1", "description": "CVAT single-page application", "main": "src/index.tsx", "scripts": { diff --git a/cvat-ui/src/actions/annotation-actions.ts b/cvat-ui/src/actions/annotation-actions.ts index 3bea5b6caa1e..efeaa81e8ff0 100644 --- a/cvat-ui/src/actions/annotation-actions.ts +++ b/cvat-ui/src/actions/annotation-actions.ts @@ -20,6 +20,7 @@ import { Rotation, ContextMenuType, Workspace, + Model, } from 'reducers/interfaces'; import getCore from 'cvat-core-wrapper'; @@ -187,6 +188,7 @@ export enum AnnotationActionTypes { CHANGE_WORKSPACE = 'CHANGE_WORKSPACE', SAVE_LOGS_SUCCESS = 'SAVE_LOGS_SUCCESS', SAVE_LOGS_FAILED = 'SAVE_LOGS_FAILED', + INTERACT_WITH_CANVAS = 'INTERACT_WITH_CANVAS', } export function saveLogsAsync(): ThunkAction { @@ -1385,6 +1387,16 @@ export function pasteShapeAsync(): ThunkAction { }; } +export function interactWithCanvas(activeInteractor: Model, activeLabelID: number): AnyAction { + return { + type: AnnotationActionTypes.INTERACT_WITH_CANVAS, + payload: { + activeInteractor, + activeLabelID, + }, + }; +} + export function repeatDrawShapeAsync(): ThunkAction { return async (dispatch: ActionCreator): Promise => { const { @@ -1401,6 +1413,7 @@ export function repeatDrawShapeAsync(): ThunkAction { }, }, drawing: { + activeInteractor, activeObjectType, activeLabelID, activeShapeType, @@ -1410,6 +1423,16 @@ export function repeatDrawShapeAsync(): ThunkAction { } = getStore().getState().annotation; let activeControl = ActiveControl.CURSOR; + if (activeInteractor) { + canvasInstance.interact({ + enabled: true, + shapeType: 'points', + minPosVertices: 4, // TODO: Add parameter to interactor + }); + dispatch(interactWithCanvas(activeInteractor, activeLabelID)); + return; + } + if (activeShapeType === ShapeType.RECTANGLE) { activeControl = ActiveControl.DRAW_RECTANGLE; } else if (activeShapeType === ShapeType.POINTS) { @@ -1443,7 +1466,7 @@ export function repeatDrawShapeAsync(): ThunkAction { rectDrawingMethod: activeRectDrawingMethod, numberOfPoints: activeNumOfPoints, shapeType: activeShapeType, - crosshair: activeShapeType === ShapeType.RECTANGLE, + crosshair: [ShapeType.RECTANGLE, ShapeType.CUBOID].includes(activeShapeType), }); } }; @@ -1490,7 +1513,7 @@ export function redrawShapeAsync(): ThunkAction { enabled: true, redraw: activatedStateID, shapeType: state.shapeType, - crosshair: state.shapeType === ShapeType.RECTANGLE, + crosshair: [ShapeType.RECTANGLE, ShapeType.CUBOID].includes(state.shapeType), }); } } diff --git a/cvat-ui/src/actions/auth-actions.ts b/cvat-ui/src/actions/auth-actions.ts index f12049fa93ab..c169874759f7 100644 --- a/cvat-ui/src/actions/auth-actions.ts +++ b/cvat-ui/src/actions/auth-actions.ts @@ -25,6 +25,12 @@ export enum AuthActionTypes { CHANGE_PASSWORD_SUCCESS = 'CHANGE_PASSWORD_SUCCESS', CHANGE_PASSWORD_FAILED = 'CHANGE_PASSWORD_FAILED', SWITCH_CHANGE_PASSWORD_DIALOG = 'SWITCH_CHANGE_PASSWORD_DIALOG', + REQUEST_PASSWORD_RESET = 'REQUEST_PASSWORD_RESET', + REQUEST_PASSWORD_RESET_SUCCESS = 'REQUEST_PASSWORD_RESET_SUCCESS', + REQUEST_PASSWORD_RESET_FAILED = 'REQUEST_PASSWORD_RESET_FAILED', + RESET_PASSWORD = 'RESET_PASSWORD_CONFIRM', + RESET_PASSWORD_SUCCESS = 'RESET_PASSWORD_CONFIRM_SUCCESS', + RESET_PASSWORD_FAILED = 'RESET_PASSWORD_CONFIRM_FAILED', LOAD_AUTH_ACTIONS = 'LOAD_AUTH_ACTIONS', LOAD_AUTH_ACTIONS_SUCCESS = 'LOAD_AUTH_ACTIONS_SUCCESS', LOAD_AUTH_ACTIONS_FAILED = 'LOAD_AUTH_ACTIONS_FAILED', @@ -50,9 +56,22 @@ export const authActions = { switchChangePasswordDialog: (showChangePasswordDialog: boolean) => ( createAction(AuthActionTypes.SWITCH_CHANGE_PASSWORD_DIALOG, { showChangePasswordDialog }) ), + requestPasswordReset: () => createAction(AuthActionTypes.REQUEST_PASSWORD_RESET), + requestPasswordResetSuccess: () => createAction(AuthActionTypes.REQUEST_PASSWORD_RESET_SUCCESS), + requestPasswordResetFailed: (error: any) => ( + createAction(AuthActionTypes.REQUEST_PASSWORD_RESET_FAILED, { error }) + ), + resetPassword: () => createAction(AuthActionTypes.RESET_PASSWORD), + resetPasswordSuccess: () => createAction(AuthActionTypes.RESET_PASSWORD_SUCCESS), + resetPasswordFailed: (error: any) => ( + createAction(AuthActionTypes.RESET_PASSWORD_FAILED, { error }) + ), loadServerAuthActions: () => createAction(AuthActionTypes.LOAD_AUTH_ACTIONS), - loadServerAuthActionsSuccess: (allowChangePassword: boolean) => ( - createAction(AuthActionTypes.LOAD_AUTH_ACTIONS_SUCCESS, { allowChangePassword }) + loadServerAuthActionsSuccess: (allowChangePassword: boolean, allowResetPassword: boolean) => ( + createAction(AuthActionTypes.LOAD_AUTH_ACTIONS_SUCCESS, { + allowChangePassword, + allowResetPassword, + }) ), loadServerAuthActionsFailed: (error: any) => ( createAction(AuthActionTypes.LOAD_AUTH_ACTIONS_FAILED, { error }) @@ -75,8 +94,8 @@ export const registerAsync = ( dispatch(authActions.register()); try { - const user = await cvat.server.register(username, firstName, lastName, email, password1, password2, - confirmations); + const user = await cvat.server.register(username, firstName, lastName, email, password1, + password2, confirmations); dispatch(authActions.registerSuccess(user)); } catch (error) { @@ -135,16 +154,49 @@ export const changePasswordAsync = (oldPassword: string, } }; +export const requestPasswordResetAsync = (email: string): ThunkAction => async (dispatch) => { + dispatch(authActions.requestPasswordReset()); + + try { + await cvat.server.requestPasswordReset(email); + dispatch(authActions.requestPasswordResetSuccess()); + } catch (error) { + dispatch(authActions.requestPasswordResetFailed(error)); + } +}; + +export const resetPasswordAsync = ( + newPassword1: string, + newPassword2: string, + uid: string, + token: string, +): ThunkAction => async (dispatch) => { + dispatch(authActions.resetPassword()); + + try { + await cvat.server.resetPassword(newPassword1, newPassword2, uid, token); + dispatch(authActions.resetPasswordSuccess()); + } catch (error) { + dispatch(authActions.resetPasswordFailed(error)); + } +}; + export const loadAuthActionsAsync = (): ThunkAction => async (dispatch) => { dispatch(authActions.loadServerAuthActions()); try { const promises: Promise[] = [ isReachable(`${cvat.config.backendAPI}/auth/password/change`, 'OPTIONS'), + isReachable(`${cvat.config.backendAPI}/auth/password/reset`, 'OPTIONS'), ]; - const [allowChangePassword] = await Promise.all(promises); - - dispatch(authActions.loadServerAuthActionsSuccess(allowChangePassword)); + const [ + allowChangePassword, + allowResetPassword] = await Promise.all(promises); + + dispatch(authActions.loadServerAuthActionsSuccess( + allowChangePassword, + allowResetPassword, + )); } catch (error) { dispatch(authActions.loadServerAuthActionsFailed(error)); } diff --git a/cvat-ui/src/actions/models-actions.ts b/cvat-ui/src/actions/models-actions.ts index 1f2afe142a45..2627e5ffa786 100644 --- a/cvat-ui/src/actions/models-actions.ts +++ b/cvat-ui/src/actions/models-actions.ts @@ -10,11 +10,6 @@ export enum ModelsActionTypes { GET_MODELS = 'GET_MODELS', GET_MODELS_SUCCESS = 'GET_MODELS_SUCCESS', GET_MODELS_FAILED = 'GET_MODELS_FAILED', - DELETE_MODEL = 'DELETE_MODEL', - CREATE_MODEL = 'CREATE_MODEL', - CREATE_MODEL_SUCCESS = 'CREATE_MODEL_SUCCESS', - CREATE_MODEL_FAILED = 'CREATE_MODEL_FAILED', - CREATE_MODEL_STATUS_UPDATED = 'CREATE_MODEL_STATUS_UPDATED', START_INFERENCE_FAILED = 'START_INFERENCE_FAILED', GET_INFERENCE_STATUS_SUCCESS = 'GET_INFERENCE_STATUS_SUCCESS', GET_INFERENCE_STATUS_FAILED = 'GET_INFERENCE_STATUS_FAILED', @@ -84,8 +79,7 @@ export function getModelsAsync(): ThunkAction { dispatch(modelsActions.getModels()); try { - const models = (await core.lambda.list()) - .filter((model: Model) => ['detector', 'reid'].includes(model.type)); + const models = await core.lambda.list(); dispatch(modelsActions.getModelsSuccess(models)); } catch (error) { dispatch(modelsActions.getModelsFailed(error)); @@ -162,7 +156,6 @@ export function startInferenceAsync( return async (dispatch): Promise => { try { const requestID: string = await core.lambda.run(taskInstance, model, body); - const dispatchCallback = (action: ModelsActions): void => { dispatch(action); }; diff --git a/cvat-ui/src/actions/settings-actions.ts b/cvat-ui/src/actions/settings-actions.ts index 9108b02a21a8..737baa267a11 100644 --- a/cvat-ui/src/actions/settings-actions.ts +++ b/cvat-ui/src/actions/settings-actions.ts @@ -17,7 +17,7 @@ export enum SettingsActionTypes { CHANGE_SHAPES_OPACITY = 'CHANGE_SHAPES_OPACITY', CHANGE_SELECTED_SHAPES_OPACITY = 'CHANGE_SELECTED_SHAPES_OPACITY', CHANGE_SHAPES_COLOR_BY = 'CHANGE_SHAPES_COLOR_BY', - CHANGE_SHAPES_BLACK_BORDERS = 'CHANGE_SHAPES_BLACK_BORDERS', + CHANGE_SHAPES_OUTLINED_BORDERS = 'CHANGE_SHAPES_OUTLINED_BORDERS', CHANGE_SHAPES_SHOW_PROJECTIONS = 'CHANGE_SHAPES_SHOW_PROJECTIONS', CHANGE_SHOW_UNLABELED_REGIONS = 'CHANGE_SHOW_UNLABELED_REGIONS', CHANGE_FRAME_STEP = 'CHANGE_FRAME_STEP', @@ -63,11 +63,12 @@ export function changeShapesColorBy(colorBy: ColorBy): AnyAction { }; } -export function changeShapesBlackBorders(blackBorders: boolean): AnyAction { +export function changeShapesOutlinedBorders(outlined: boolean, color: string): AnyAction { return { - type: SettingsActionTypes.CHANGE_SHAPES_BLACK_BORDERS, + type: SettingsActionTypes.CHANGE_SHAPES_OUTLINED_BORDERS, payload: { - blackBorders, + outlined, + color, }, }; } diff --git a/cvat-ui/src/actions/tasks-actions.ts b/cvat-ui/src/actions/tasks-actions.ts index c61c12d756eb..5eb6671db603 100644 --- a/cvat-ui/src/actions/tasks-actions.ts +++ b/cvat-ui/src/actions/tasks-actions.ts @@ -386,6 +386,7 @@ ThunkAction, {}, {}, AnyAction> { z_order: data.advanced.zOrder, image_quality: 70, use_zip_chunks: data.advanced.useZipChunks, + use_cache: data.advanced.useCache, }; if (data.advanced.bugTracker) { diff --git a/cvat-ui/src/assets/ai-tools-icon.svg b/cvat-ui/src/assets/ai-tools-icon.svg new file mode 100644 index 000000000000..c8b4f304b8d4 --- /dev/null +++ b/cvat-ui/src/assets/ai-tools-icon.svg @@ -0,0 +1,18 @@ + + + + + diff --git a/cvat-ui/src/base.scss b/cvat-ui/src/base.scss index 75b561ca0245..86bae7310c1a 100644 --- a/cvat-ui/src/base.scss +++ b/cvat-ui/src/base.scss @@ -22,7 +22,7 @@ $info-icon-color: #0074d9; $objects-bar-tabs-color: #bebebe; $objects-bar-icons-color: #242424; // #6e6e6e $active-label-background-color: #d8ecff; -$object-item-border-color: #000; +$object-item-border-color: rgba(0, 0, 0, 0.7); $slider-color: #1890ff; $monospaced-fonts-stack: Consolas, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace; diff --git a/cvat-ui/src/components/annotation-page/appearance-block.tsx b/cvat-ui/src/components/annotation-page/appearance-block.tsx index 06a060fa72a4..42e8fc201c61 100644 --- a/cvat-ui/src/components/annotation-page/appearance-block.tsx +++ b/cvat-ui/src/components/annotation-page/appearance-block.tsx @@ -11,6 +11,8 @@ import Slider, { SliderValue } from 'antd/lib/slider'; import Checkbox, { CheckboxChangeEvent } from 'antd/lib/checkbox'; import Collapse from 'antd/lib/collapse'; +import ColorPicker from 'components/annotation-page/standard-workspace/objects-side-bar/color-picker'; +import { ColorizeIcon } from 'icons'; import { ColorBy, CombinedState } from 'reducers/interfaces'; import { collapseAppearance as collapseAppearanceAction, @@ -20,17 +22,19 @@ import { changeShapesColorBy as changeShapesColorByAction, changeShapesOpacity as changeShapesOpacityAction, changeSelectedShapesOpacity as changeSelectedShapesOpacityAction, - changeShapesBlackBorders as changeShapesBlackBordersAction, + changeShapesOutlinedBorders as changeShapesOutlinedBordersAction, changeShowBitmap as changeShowBitmapAction, changeShowProjections as changeShowProjectionsAction, } from 'actions/settings-actions'; +import Button from 'antd/lib/button'; interface StateToProps { appearanceCollapsed: boolean; colorBy: ColorBy; opacity: number; selectedOpacity: number; - blackBorders: boolean; + outlined: boolean; + outlineColor: string; showBitmap: boolean; showProjections: boolean; } @@ -40,7 +44,7 @@ interface DispatchToProps { changeShapesColorBy(event: RadioChangeEvent): void; changeShapesOpacity(event: SliderValue): void; changeSelectedShapesOpacity(event: SliderValue): void; - changeShapesBlackBorders(event: CheckboxChangeEvent): void; + changeShapesOutlinedBorders(outlined: boolean, color: string): void; changeShowBitmap(event: CheckboxChangeEvent): void; changeShowProjections(event: CheckboxChangeEvent): void; } @@ -72,7 +76,8 @@ function mapStateToProps(state: CombinedState): StateToProps { colorBy, opacity, selectedOpacity, - blackBorders, + outlined, + outlineColor, showBitmap, showProjections, }, @@ -84,7 +89,8 @@ function mapStateToProps(state: CombinedState): StateToProps { colorBy, opacity, selectedOpacity, - blackBorders, + outlined, + outlineColor, showBitmap, showProjections, }; @@ -119,8 +125,8 @@ function mapDispatchToProps(dispatch: Dispatch): DispatchToProps { changeSelectedShapesOpacity(value: SliderValue): void { dispatch(changeSelectedShapesOpacityAction(value as number)); }, - changeShapesBlackBorders(event: CheckboxChangeEvent): void { - dispatch(changeShapesBlackBordersAction(event.target.checked)); + changeShapesOutlinedBorders(outlined: boolean, color: string): void { + dispatch(changeShapesOutlinedBordersAction(outlined, color)); }, changeShowBitmap(event: CheckboxChangeEvent): void { dispatch(changeShowBitmapAction(event.target.checked)); @@ -139,14 +145,15 @@ function AppearanceBlock(props: Props): JSX.Element { colorBy, opacity, selectedOpacity, - blackBorders, + outlined, + outlineColor, showBitmap, showProjections, collapseAppearance, changeShapesColorBy, changeShapesOpacity, changeSelectedShapesOpacity, - changeShapesBlackBorders, + changeShapesOutlinedBorders, changeShowBitmap, changeShowProjections, } = props; @@ -185,10 +192,22 @@ function AppearanceBlock(props: Props): JSX.Element { max={100} /> { + changeShapesOutlinedBorders(event.target.checked, outlineColor); + }} + checked={outlined} > - Black borders + Outlined borders + changeShapesOutlinedBorders(outlined, color)} + value={outlineColor} + placement='top' + resetVisible={false} + > + + - + , window.document.body, ); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx index 14a13014e762..35de27e1c2cf 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/canvas-wrapper.tsx @@ -42,7 +42,8 @@ interface Props { opacity: number; colorBy: ColorBy; selectedOpacity: number; - blackBorders: boolean; + outlined: boolean; + outlineColor: string; showBitmap: boolean; showProjections: boolean; grid: boolean; @@ -125,7 +126,8 @@ export default class CanvasWrapperComponent extends React.PureComponent { opacity, colorBy, selectedOpacity, - blackBorders, + outlined, + outlineColor, showBitmap, frameData, frameAngle, @@ -230,7 +232,8 @@ export default class CanvasWrapperComponent extends React.PureComponent { }, { once: true }); } - if (prevProps.opacity !== opacity || prevProps.blackBorders !== blackBorders + if (prevProps.opacity !== opacity || prevProps.outlined !== outlined + || prevProps.outlineColor !== outlineColor || prevProps.selectedOpacity !== selectedOpacity || prevProps.colorBy !== colorBy ) { this.updateShapesView(); @@ -602,7 +605,8 @@ export default class CanvasWrapperComponent extends React.PureComponent { annotations, opacity, colorBy, - blackBorders, + outlined, + outlineColor, } = this.props; for (const state of annotations) { @@ -625,7 +629,7 @@ export default class CanvasWrapperComponent extends React.PureComponent { } (shapeView as any).instance.fill({ color: shapeColor, opacity: opacity / 100 }); - (shapeView as any).instance.stroke({ color: blackBorders ? 'black' : shapeColor }); + (shapeView as any).instance.stroke({ color: outlined ? outlineColor : shapeColor }); } } } diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx index f6ffeb2ae5bc..a202d5c99ac4 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/controls-side-bar.tsx @@ -14,6 +14,7 @@ import CursorControl from './cursor-control'; import MoveControl from './move-control'; import FitControl from './fit-control'; import ResizeControl from './resize-control'; +import ToolsControl from './tools-control'; import DrawRectangleControl from './draw-rectangle-control'; import DrawPolygonControl from './draw-polygon-control'; import DrawPolylineControl from './draw-polyline-control'; @@ -84,7 +85,7 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { preventDefault(event); const drawing = [ActiveControl.DRAW_POINTS, ActiveControl.DRAW_POLYGON, ActiveControl.DRAW_POLYLINE, ActiveControl.DRAW_RECTANGLE, - ActiveControl.DRAW_CUBOID].includes(activeControl); + ActiveControl.DRAW_CUBOID, ActiveControl.INTERACTION].includes(activeControl); if (!drawing) { canvasInstance.cancel(); @@ -97,6 +98,12 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element { repeatDrawShape(); } } else { + if (activeControl === ActiveControl.INTERACTION) { + // separated API method + canvasInstance.interact({ enabled: false }); + return; + } + canvasInstance.draw({ enabled: false }); } }, @@ -178,7 +185,7 @@ export default function ControlsSideBarComponent(props: Props): JSX.Element {
- + - { - setActivated(event.target.checked); - if (event.target.checked) { - activate(canvasInstance); - } else { - deactivate(canvasInstance); - } - }} - > - Make AI polygon - - - ) : null - ); -} - -export default connect( - mapStateToProps, - mapDispatchToProps, -)(DEXTRPlugin); - -// TODO: Add dialog window with cancel button diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx index 5edfc758735e..d8ce4d33867e 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/draw-shape-popover.tsx @@ -14,7 +14,6 @@ import Text from 'antd/lib/typography/Text'; import { RectDrawingMethod, CuboidDrawingMethod } from 'cvat-canvas-wrapper'; import { ShapeType } from 'reducers/interfaces'; import { clamp } from 'utils/math'; -import DEXTRPlugin from './dextr-plugin'; interface Props { shapeType: ShapeType; @@ -91,7 +90,6 @@ function DrawShapePopoverComponent(props: Props): JSX.Element { - { shapeType === ShapeType.POLYGON && } { shapeType === ShapeType.RECTANGLE && ( <> diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/tools-control.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/tools-control.tsx new file mode 100644 index 000000000000..5bdae3d45ade --- /dev/null +++ b/cvat-ui/src/components/annotation-page/standard-workspace/controls-side-bar/tools-control.tsx @@ -0,0 +1,472 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import { connect } from 'react-redux'; +import Icon from 'antd/lib/icon'; +import Popover from 'antd/lib/popover'; +import Select, { OptionProps } from 'antd/lib/select'; +import Button from 'antd/lib/button'; +import Modal from 'antd/lib/modal'; +import Text from 'antd/lib/typography/Text'; +import { Row, Col } from 'antd/lib/grid'; +import notification from 'antd/lib/notification'; + +import { AIToolsIcon } from 'icons'; +import { Canvas } from 'cvat-canvas-wrapper'; +import getCore from 'cvat-core-wrapper'; +import { + CombinedState, + ActiveControl, + Model, + ObjectType, + ShapeType, +} from 'reducers/interfaces'; +import { interactWithCanvas, fetchAnnotationsAsync, updateAnnotationsAsync } from 'actions/annotation-actions'; +import { InteractionResult } from 'cvat-canvas/src/typescript/canvas'; + +interface StateToProps { + canvasInstance: Canvas; + labels: any[]; + states: any[]; + activeLabelID: number; + jobInstance: any; + isInteraction: boolean; + frame: number; + interactors: Model[]; +} + +interface DispatchToProps { + onInteractionStart(activeInteractor: Model, activeLabelID: number): void; + updateAnnotations(statesToUpdate: any[]): void; + fetchAnnotations(): void; +} + +const core = getCore(); + +function mapStateToProps(state: CombinedState): StateToProps { + const { annotation } = state; + const { number: frame } = annotation.player.frame; + const { instance: jobInstance } = annotation.job; + const { instance: canvasInstance, activeControl } = annotation.canvas; + const { models } = state; + const { interactors } = models; + + return { + interactors, + isInteraction: activeControl === ActiveControl.INTERACTION, + activeLabelID: annotation.drawing.activeLabelID, + labels: annotation.job.labels, + states: annotation.annotations.states, + canvasInstance, + jobInstance, + frame, + }; +} + +function mapDispatchToProps(dispatch: any): DispatchToProps { + return { + onInteractionStart(activeInteractor: Model, activeLabelID: number): void { + dispatch(interactWithCanvas(activeInteractor, activeLabelID)); + }, + updateAnnotations(statesToUpdate: any[]): void { + dispatch(updateAnnotationsAsync(statesToUpdate)); + }, + fetchAnnotations(): void { + dispatch(fetchAnnotationsAsync()); + }, + }; +} + +function convertShapesForInteractor(shapes: InteractionResult[]): number[][] { + const reducer = (acc: number[][], _: number, index: number, array: number[]): number[][] => { + if (!(index % 2)) { // 0, 2, 4 + acc.push([ + array[index], + array[index + 1], + ]); + } + return acc; + }; + + return shapes.filter((shape: InteractionResult): boolean => shape.shapeType === 'points' && shape.button === 0) + .map((shape: InteractionResult): number[] => shape.points) + .flat().reduce(reducer, []); +} + +type Props = StateToProps & DispatchToProps; +interface State { + activeInteractor: Model | null; + activeLabelID: number; + interactiveStateID: number | null; + fetching: boolean; +} + +class ToolsControlComponent extends React.PureComponent { + private interactionIsAborted: boolean; + private interactionIsDone: boolean; + + public constructor(props: Props) { + super(props); + this.state = { + activeInteractor: props.interactors.length ? props.interactors[0] : null, + activeLabelID: props.labels[0].id, + interactiveStateID: null, + fetching: false, + }; + + this.interactionIsAborted = false; + this.interactionIsDone = false; + } + + public componentDidMount(): void { + const { canvasInstance } = this.props; + canvasInstance.html().addEventListener('canvas.interacted', this.interactionListener); + canvasInstance.html().addEventListener('canvas.canceled', this.cancelListener); + } + + public componentDidUpdate(prevProps: Props): void { + const { isInteraction } = this.props; + if (prevProps.isInteraction && !isInteraction) { + window.removeEventListener('contextmenu', this.contextmenuDisabler); + } else if (!prevProps.isInteraction && isInteraction) { + this.interactionIsDone = false; + this.interactionIsAborted = false; + window.addEventListener('contextmenu', this.contextmenuDisabler); + } + } + + public componentWillUnmount(): void { + const { canvasInstance } = this.props; + canvasInstance.html().removeEventListener('canvas.interacted', this.interactionListener); + canvasInstance.html().removeEventListener('canvas.canceled', this.cancelListener); + } + + private getInteractiveState(): any | null { + const { states } = this.props; + const { interactiveStateID } = this.state; + return states + .filter((_state: any): boolean => _state.clientID === interactiveStateID)[0] || null; + } + + private contextmenuDisabler = (e: MouseEvent): void => { + if (e.target && (e.target as Element).classList + && (e.target as Element).classList.toString().includes('ant-modal')) { + e.preventDefault(); + } + }; + + private cancelListener = async (): Promise => { + const { + isInteraction, + jobInstance, + frame, + fetchAnnotations, + } = this.props; + const { interactiveStateID, fetching } = this.state; + + if (isInteraction) { + if (fetching && !this.interactionIsDone) { + // user pressed ESC + this.setState({ fetching: false }); + this.interactionIsAborted = true; + } + + if (interactiveStateID !== null) { + const state = this.getInteractiveState(); + this.setState({ interactiveStateID: null }); + await state.delete(frame); + fetchAnnotations(); + } + + await jobInstance.actions.freeze(false); + } + }; + + private interactionListener = async (e: Event): Promise => { + const { + frame, + labels, + jobInstance, + isInteraction, + activeLabelID, + fetchAnnotations, + updateAnnotations, + } = this.props; + const { activeInteractor, interactiveStateID, fetching } = this.state; + + try { + if (!isInteraction) { + throw Error('Canvas raises event "canvas.interacted" when interaction is off'); + } + + if (fetching) { + this.interactionIsDone = (e as CustomEvent).detail.isDone; + return; + } + + const interactor = activeInteractor as Model; + + let result = []; + if ((e as CustomEvent).detail.shapesUpdated) { + this.setState({ fetching: true }); + try { + result = await core.lambda.call(jobInstance.task, interactor, { + task: jobInstance.task, + frame, + points: convertShapesForInteractor((e as CustomEvent).detail.shapes), + }); + + if (this.interactionIsAborted) { + // while the server request + // user has cancelled interaction (for example pressed ESC) + return; + } + } finally { + this.setState({ fetching: false }); + } + } + + if (this.interactionIsDone) { + // while the server request, user has done interaction (for example pressed N) + const object = new core.classes.ObjectState({ + frame, + objectType: ObjectType.SHAPE, + label: labels + .filter((label: any) => label.id === activeLabelID)[0], + shapeType: ShapeType.POLYGON, + points: result.flat(), + occluded: false, + zOrder: (e as CustomEvent).detail.zOrder, + }); + + await jobInstance.annotations.put([object]); + fetchAnnotations(); + } else { + // no shape yet, then create it and save to collection + if (interactiveStateID === null) { + // freeze history for interaction time + // (points updating shouldn't cause adding new actions to history) + await jobInstance.actions.freeze(true); + const object = new core.classes.ObjectState({ + frame, + objectType: ObjectType.SHAPE, + label: labels + .filter((label: any) => label.id === activeLabelID)[0], + shapeType: ShapeType.POLYGON, + points: result.flat(), + occluded: false, + zOrder: (e as CustomEvent).detail.zOrder, + }); + // need a clientID of a created object to interact with it further + // so, we do not use createAnnotationAction + const [clientID] = await jobInstance.annotations.put([object]); + + // update annotations on a canvas + fetchAnnotations(); + this.setState({ interactiveStateID: clientID }); + return; + } + + const state = this.getInteractiveState(); + if ((e as CustomEvent).detail.isDone) { + const finalObject = new core.classes.ObjectState({ + frame: state.frame, + objectType: state.objectType, + label: state.label, + shapeType: state.shapeType, + points: result.length ? result.flat() : state.points, + occluded: state.occluded, + zOrder: state.zOrder, + }); + this.setState({ interactiveStateID: null }); + await state.delete(frame); + await jobInstance.actions.freeze(false); + await jobInstance.annotations.put([finalObject]); + fetchAnnotations(); + } else { + state.points = result.flat(); + updateAnnotations([state]); + fetchAnnotations(); + } + } + } catch (err) { + notification.error({ + description: err.toString(), + message: 'Interaction error occured', + }); + } + }; + + private setActiveInteractor = (key: string): void => { + const { interactors } = this.props; + this.setState({ + activeInteractor: interactors.filter( + (interactor: Model) => interactor.id === key, + )[0], + }); + }; + + private renderLabelBlock(): JSX.Element { + const { labels } = this.props; + const { activeLabelID } = this.state; + return ( + <> + + + Label + + + + + + + + + ); + } + + private renderInteractorBlock(): JSX.Element { + const { interactors, canvasInstance, onInteractionStart } = this.props; + const { activeInteractor, activeLabelID, fetching } = this.state; + + return ( + <> + + + Interactor + + + + + + + + + + + + + + ); + } + + private renderPopoverContent(): JSX.Element { + return ( +
+ + + AI Tools + + + { this.renderLabelBlock() } + { this.renderInteractorBlock() } +
+ ); + } + + public render(): JSX.Element | null { + const { interactors, isInteraction, canvasInstance } = this.props; + const { fetching } = this.state; + + if (!interactors.length) return null; + + const dynamcPopoverPros = isInteraction ? { + overlayStyle: { + display: 'none', + }, + } : {}; + + const dynamicIconProps = isInteraction ? { + className: 'cvat-active-canvas-control cvat-tools-control', + onClick: (): void => { + canvasInstance.interact({ enabled: false }); + }, + } : { + className: 'cvat-tools-control', + }; + + return ( + <> + + Waiting for a server response.. + + + + + + + ); + } +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ToolsControlComponent); diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx index f91989ad3363..456826264d88 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx +++ b/cvat-ui/src/components/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx @@ -12,7 +12,7 @@ interface Props { listHeight: number; statesHidden: boolean; statesLocked: boolean; - statesCollapsed: boolean; + statesCollapsedAll: boolean; statesOrdering: StatesOrdering; sortedStatesID: number[]; switchLockAllShortcut: string; @@ -31,7 +31,7 @@ function ObjectListComponent(props: Props): JSX.Element { listHeight, statesHidden, statesLocked, - statesCollapsed, + statesCollapsedAll, statesOrdering, sortedStatesID, switchLockAllShortcut, @@ -50,7 +50,7 @@ function ObjectListComponent(props: Props): JSX.Element {
{ sortedStatesID.map((id: number): JSX.Element => ( - + ))}
diff --git a/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss b/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss index a9514add5d2a..71eb7e490838 100644 --- a/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss +++ b/cvat-ui/src/components/annotation-page/standard-workspace/styles.scss @@ -83,17 +83,31 @@ padding: 0; } -.cvat-draw-shape-popover > -.ant-popover-content > -.ant-popover-inner > div > -.ant-popover-inner-content { - padding: 0; +.cvat-draw-shape-popover, +.cvat-tools-control-popover { + > .ant-popover-content > + .ant-popover-inner > div > + .ant-popover-inner-content { + padding: 0; + } +} + +.cvat-tools-interact-button { + width: 100%; + margin-top: 10px; } .cvat-draw-shape-popover-points-selector { width: 100%; } +.cvat-tools-control-popover-content { + padding: 10px; + border-radius: 5px; + background: $background-color-2; + width: 270px; +} + .cvat-draw-shape-popover-content { padding: 10px; border-radius: 5px; diff --git a/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx b/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx index b60b16e3f235..00efde947320 100644 --- a/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx +++ b/cvat-ui/src/components/create-task-page/advanced-configuration-form.tsx @@ -26,6 +26,7 @@ export interface AdvancedConfiguration { repository?: string; useZipChunks: boolean; dataChunkSize?: number; + useCache: boolean; } type Props = FormComponentProps & { @@ -380,6 +381,24 @@ class AdvancedConfigurationForm extends React.PureComponent { ); } + private renderCreateTaskMethod(): JSX.Element { + const { form } = this.props; + return ( + + {form.getFieldDecorator('useCache', { + initialValue: false, + valuePropName: 'checked', + })( + + + Use cache + + , + )} + + ); + } + private renderChunkSize(): JSX.Element { const { form } = this.props; @@ -434,6 +453,12 @@ class AdvancedConfigurationForm extends React.PureComponent { + + + {this.renderCreateTaskMethod()} + + + {this.renderImageQuality()} diff --git a/cvat-ui/src/components/create-task-page/create-task-content.tsx b/cvat-ui/src/components/create-task-page/create-task-content.tsx index 18de3d7aa348..3a1e311be97a 100644 --- a/cvat-ui/src/components/create-task-page/create-task-content.tsx +++ b/cvat-ui/src/components/create-task-page/create-task-content.tsx @@ -42,6 +42,7 @@ const defaultState = { zOrder: false, lfs: false, useZipChunks: true, + useCache: true, }, labels: [], files: { diff --git a/cvat-ui/src/components/cvat-app.tsx b/cvat-ui/src/components/cvat-app.tsx index 5c1af2cf9266..c32717c73dfa 100644 --- a/cvat-ui/src/components/cvat-app.tsx +++ b/cvat-ui/src/components/cvat-app.tsx @@ -23,6 +23,8 @@ import ModelsPageContainer from 'containers/models-page/models-page'; import AnnotationPageContainer from 'containers/annotation-page/annotation-page'; import LoginPageContainer from 'containers/login-page/login-page'; import RegisterPageContainer from 'containers/register-page/register-page'; +import ResetPasswordPageComponent from 'components/reset-password-page/reset-password-page'; +import ResetPasswordPageConfirmComponent from 'components/reset-password-confirm-page/reset-password-confirm-page'; import Header from 'components/header/header'; import { customWaViewHit } from 'utils/enviroment'; import showPlatformNotification, { stopNotifications, platformInfo } from 'utils/platform-checker'; @@ -61,7 +63,6 @@ interface CVATAppProps { userAgreementsInitialized: boolean; authActionsFetching: boolean; authActionsInitialized: boolean; - allowChangePassword: boolean; notifications: NotificationsState; user: any; } @@ -332,6 +333,8 @@ class CVATApplication extends React.PureComponent + + diff --git a/cvat-ui/src/components/login-page/login-page.tsx b/cvat-ui/src/components/login-page/login-page.tsx index 87cebb51446e..23dff9e1e0fb 100644 --- a/cvat-ui/src/components/login-page/login-page.tsx +++ b/cvat-ui/src/components/login-page/login-page.tsx @@ -14,6 +14,7 @@ import CookieDrawer from './cookie-policy-drawer'; interface LoginPageComponentProps { fetching: boolean; + renderResetPassword: boolean; onLogin: (username: string, password: string) => void; } @@ -29,6 +30,7 @@ function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps const { fetching, onLogin, + renderResetPassword, } = props; return ( @@ -50,6 +52,16 @@ function LoginPageComponent(props: LoginPageComponentProps & RouteComponentProps + { renderResetPassword + && ( + + + + Forgot your password? + + + + )} diff --git a/cvat-ui/src/components/model-runner-modal/model-runner-modal.tsx b/cvat-ui/src/components/model-runner-modal/model-runner-modal.tsx index 234c7fd0850c..d8fd959ecb96 100644 --- a/cvat-ui/src/components/model-runner-modal/model-runner-modal.tsx +++ b/cvat-ui/src/components/model-runner-modal/model-runner-modal.tsx @@ -21,7 +21,8 @@ import { } from 'reducers/interfaces'; interface Props { - models: Model[]; + reid: Model[]; + detectors: Model[]; activeProcesses: StringObject; visible: boolean; taskInstance: any; @@ -88,14 +89,14 @@ export default class ModelRunnerModalComponent extends React.PureComponent @@ -166,10 +168,7 @@ export default class ModelRunnerModalComponent extends React.PureComponent @@ -203,11 +202,7 @@ export default class ModelRunnerModalComponent extends React.PureComponent @@ -346,16 +338,10 @@ export default class ModelRunnerModalComponent extends React.PureComponent _model.name === selectedModel)[0]; @@ -414,13 +400,15 @@ export default class ModelRunnerModalComponent extends React.PureComponent model.name === selectedModel, )[0]; diff --git a/cvat-ui/src/components/models-page/built-model-item.tsx b/cvat-ui/src/components/models-page/built-model-item.tsx deleted file mode 100644 index f100dfe5a4e0..000000000000 --- a/cvat-ui/src/components/models-page/built-model-item.tsx +++ /dev/null @@ -1,49 +0,0 @@ -// Copyright (C) 2020 Intel Corporation -// -// SPDX-License-Identifier: MIT - -import React from 'react'; -import { Row, Col } from 'antd/lib/grid'; -import Tag from 'antd/lib/tag'; -import Select from 'antd/lib/select'; -import Text from 'antd/lib/typography/Text'; - -import { Model } from 'reducers/interfaces'; - -interface Props { - model: Model; -} - -export default function BuiltModelItemComponent(props: Props): JSX.Element { - const { model } = props; - - return ( - - - {model.framework} - - - - {model.name} - - - - - - - - ); -} diff --git a/cvat-ui/src/components/models-page/deployed-models-list.tsx b/cvat-ui/src/components/models-page/deployed-models-list.tsx index 93e301be2ef0..45d62aa87b1d 100644 --- a/cvat-ui/src/components/models-page/deployed-models-list.tsx +++ b/cvat-ui/src/components/models-page/deployed-models-list.tsx @@ -9,7 +9,6 @@ import Text from 'antd/lib/typography/Text'; import { Model } from 'reducers/interfaces'; import DeployedModelItem from './deployed-model-item'; - interface Props { models: Model[]; } diff --git a/cvat-ui/src/components/models-page/models-page.tsx b/cvat-ui/src/components/models-page/models-page.tsx index a5606a2ade15..3b4fe521c516 100644 --- a/cvat-ui/src/components/models-page/models-page.tsx +++ b/cvat-ui/src/components/models-page/models-page.tsx @@ -12,11 +12,21 @@ import FeedbackComponent from '../feedback/feedback'; import { Model } from '../../reducers/interfaces'; interface Props { - deployedModels: Model[]; + interactors: Model[]; + detectors: Model[]; + trackers: Model[]; + reid: Model[]; } export default function ModelsPageComponent(props: Props): JSX.Element { - const { deployedModels } = props; + const { + interactors, + detectors, + trackers, + reid, + } = props; + + const deployedModels = [...detectors, ...interactors, ...trackers, ...reid]; return (
diff --git a/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-form.tsx b/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-form.tsx new file mode 100644 index 000000000000..38d4fdb06ee6 --- /dev/null +++ b/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-form.tsx @@ -0,0 +1,156 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import Form, { FormComponentProps } from 'antd/lib/form/Form'; +import Button from 'antd/lib/button'; +import Icon from 'antd/lib/icon'; +import Input from 'antd/lib/input'; + +import patterns from 'utils/validation-patterns'; + +export interface ResetPasswordConfirmData { + newPassword1: string; + newPassword2: string; + uid: string; + token: string; +} + +type ResetPasswordConfirmFormProps = { + fetching: boolean; + onSubmit(resetPasswordConfirmData: ResetPasswordConfirmData): void; +} & FormComponentProps & RouteComponentProps; + +class ResetPasswordConfirmFormComponent extends React.PureComponent { + private validateConfirmation = (_: any, value: string, callback: Function): void => { + const { form } = this.props; + if (value && value !== form.getFieldValue('newPassword1')) { + callback('Passwords do not match!'); + } else { + callback(); + } + }; + + private validatePassword = (_: any, value: string, callback: Function): void => { + const { form } = this.props; + if (!patterns.validatePasswordLength.pattern.test(value)) { + callback(patterns.validatePasswordLength.message); + } + + if (!patterns.passwordContainsNumericCharacters.pattern.test(value)) { + callback(patterns.passwordContainsNumericCharacters.message); + } + + if (!patterns.passwordContainsUpperCaseCharacter.pattern.test(value)) { + callback(patterns.passwordContainsUpperCaseCharacter.message); + } + + if (!patterns.passwordContainsLowerCaseCharacter.pattern.test(value)) { + callback(patterns.passwordContainsLowerCaseCharacter.message); + } + + if (value) { + form.validateFields(['newPassword2'], { force: true }); + } + callback(); + }; + + private handleSubmit = (e: React.FormEvent): void => { + e.preventDefault(); + const { + form, + onSubmit, + location, + } = this.props; + + const params = new URLSearchParams(location.search); + const uid = params.get('uid'); + const token = params.get('token'); + + form.validateFields((error, values): void => { + if (!error) { + const validatedFields = { + ...values, + uid, + token, + }; + + onSubmit(validatedFields); + } + }); + }; + + private renderNewPasswordField(): JSX.Element { + const { form } = this.props; + + return ( + + {form.getFieldDecorator('newPassword1', { + rules: [{ + required: true, + message: 'Please input new password!', + }, { + validator: this.validatePassword, + }], + })(} + placeholder='New password' + />)} + + ); + } + + private renderNewPasswordConfirmationField(): JSX.Element { + const { form } = this.props; + + return ( + + {form.getFieldDecorator('newPassword2', { + rules: [{ + required: true, + message: 'Please confirm your new password!', + }, { + validator: this.validateConfirmation, + }], + })(} + placeholder='Confirm new password' + />)} + + ); + } + + public render(): JSX.Element { + const { fetching } = this.props; + + return ( +
+ {this.renderNewPasswordField()} + {this.renderNewPasswordConfirmationField()} + + + + +
+ ); + } +} + +export default withRouter( + Form.create()(ResetPasswordConfirmFormComponent), +); diff --git a/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-page.tsx b/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-page.tsx new file mode 100644 index 000000000000..50a4af5dcd44 --- /dev/null +++ b/cvat-ui/src/components/reset-password-confirm-page/reset-password-confirm-page.tsx @@ -0,0 +1,83 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { connect } from 'react-redux'; +import Title from 'antd/lib/typography/Title'; +import Text from 'antd/lib/typography/Text'; +import { Row, Col } from 'antd/lib/grid'; + +import { CombinedState } from 'reducers/interfaces'; +import { resetPasswordAsync } from 'actions/auth-actions'; + +import ResetPasswordConfirmForm, { ResetPasswordConfirmData } from './reset-password-confirm-form'; + +interface StateToProps { + fetching: boolean; +} + +interface DispatchToProps { + onResetPasswordConfirm: typeof resetPasswordAsync; +} + +interface ResetPasswordConfirmPageComponentProps { + fetching: boolean; + onResetPasswordConfirm: ( + newPassword1: string, + newPassword2: string, + uid: string, + token: string) => void; +} + +function mapStateToProps(state: CombinedState): StateToProps { + return { + fetching: state.auth.fetching, + }; +} + +const mapDispatchToProps: DispatchToProps = { + onResetPasswordConfirm: resetPasswordAsync, +}; + +function ResetPasswordPagePageComponent( + props: ResetPasswordConfirmPageComponentProps, +): JSX.Element { + const sizes = { + xs: { span: 14 }, + sm: { span: 14 }, + md: { span: 10 }, + lg: { span: 4 }, + xl: { span: 4 }, + }; + + const { + fetching, + onResetPasswordConfirm, + } = props; + + return ( + + + Change password + { + onResetPasswordConfirm( + resetPasswordConfirmData.newPassword1, + resetPasswordConfirmData.newPassword2, + resetPasswordConfirmData.uid, + resetPasswordConfirmData.token, + ); + }} + /> + + + ); +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ResetPasswordPagePageComponent); diff --git a/cvat-ui/src/components/reset-password-page/reset-password-form.tsx b/cvat-ui/src/components/reset-password-page/reset-password-form.tsx new file mode 100644 index 000000000000..9b0b1b3b0e81 --- /dev/null +++ b/cvat-ui/src/components/reset-password-page/reset-password-form.tsx @@ -0,0 +1,81 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import Form, { FormComponentProps } from 'antd/lib/form/Form'; +import Button from 'antd/lib/button'; +import Icon from 'antd/lib/icon'; +import Input from 'antd/lib/input'; + +export interface ResetPasswordData { + email: string; +} + +type ResetPasswordFormProps = { + fetching: boolean; + onSubmit(resetPasswordData: ResetPasswordData): void; +} & FormComponentProps; + +class ResetPasswordFormComponent extends React.PureComponent { + private handleSubmit = (e: React.FormEvent): void => { + e.preventDefault(); + const { + form, + onSubmit, + } = this.props; + + form.validateFields((error, values): void => { + if (!error) { + onSubmit(values); + } + }); + }; + + private renderEmailField(): JSX.Element { + const { form } = this.props; + + return ( + + {form.getFieldDecorator('email', { + rules: [{ + type: 'email', + message: 'The input is not valid E-mail!', + }, { + required: true, + message: 'Please specify an email address', + }], + })( + } + placeholder='Email address' + />, + )} + + ); + } + + public render(): JSX.Element { + const { fetching } = this.props; + return ( +
+ {this.renderEmailField()} + + + + +
+ ); + } +} + +export default Form.create()(ResetPasswordFormComponent); diff --git a/cvat-ui/src/components/reset-password-page/reset-password-page.tsx b/cvat-ui/src/components/reset-password-page/reset-password-page.tsx new file mode 100644 index 000000000000..0b7ebcca986f --- /dev/null +++ b/cvat-ui/src/components/reset-password-page/reset-password-page.tsx @@ -0,0 +1,79 @@ +// Copyright (C) 2020 Intel Corporation +// +// SPDX-License-Identifier: MIT + +import React from 'react'; +import { Link } from 'react-router-dom'; +import { connect } from 'react-redux'; +import Title from 'antd/lib/typography/Title'; +import Text from 'antd/lib/typography/Text'; +import { Row, Col } from 'antd/lib/grid'; + +import { requestPasswordResetAsync } from 'actions/auth-actions'; +import { CombinedState } from 'reducers/interfaces'; +import ResetPasswordForm, { ResetPasswordData } from './reset-password-form'; + +interface StateToProps { + fetching: boolean; +} + +interface DispatchToProps { + onResetPassword: typeof requestPasswordResetAsync; +} + +interface ResetPasswordPageComponentProps { + fetching: boolean; + onResetPassword: (email: string) => void; +} + +function mapStateToProps(state: CombinedState): StateToProps { + return { + fetching: state.auth.fetching, + }; +} + +const mapDispatchToProps: DispatchToProps = { + onResetPassword: requestPasswordResetAsync, +}; + +function ResetPasswordPagePageComponent(props: ResetPasswordPageComponentProps): JSX.Element { + const sizes = { + xs: { span: 14 }, + sm: { span: 14 }, + md: { span: 10 }, + lg: { span: 4 }, + xl: { span: 4 }, + }; + + const { + fetching, + onResetPassword, + } = props; + + return ( + + + Reset password + { + onResetPassword(resetPasswordData.email); + }} + /> + + + + Go to + login page + + + + + + ); +} + +export default connect( + mapStateToProps, + mapDispatchToProps, +)(ResetPasswordPagePageComponent); diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx index 4591ad5a1600..513b289a1ef8 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/canvas-wrapper.tsx @@ -64,7 +64,8 @@ interface StateToProps { opacity: number; colorBy: ColorBy; selectedOpacity: number; - blackBorders: boolean; + outlined: boolean; + outlineColor: string; showBitmap: boolean; showProjections: boolean; grid: boolean; @@ -179,7 +180,8 @@ function mapStateToProps(state: CombinedState): StateToProps { opacity, colorBy, selectedOpacity, - blackBorders, + outlined, + outlineColor, showBitmap, showProjections, }, @@ -204,7 +206,8 @@ function mapStateToProps(state: CombinedState): StateToProps { opacity, colorBy, selectedOpacity, - blackBorders, + outlined, + outlineColor, showBitmap, showProjections, grid, diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx index e0b29e369c01..09701627318c 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/object-item.tsx @@ -30,6 +30,7 @@ import { shift } from 'utils/math'; interface OwnProps { clientID: number; + initialCollapsed: boolean; } interface StateToProps { @@ -101,7 +102,7 @@ function mapStateToProps(state: CombinedState, own: OwnProps): StateToProps { .indexOf(own.clientID); const collapsedState = typeof (statesCollapsed[own.clientID]) === 'undefined' - ? true : statesCollapsed[own.clientID]; + ? own.initialCollapsed : statesCollapsed[own.clientID]; return { objectState: states[index], diff --git a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx index 14e445deee97..58204bc282f1 100644 --- a/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx +++ b/cvat-ui/src/containers/annotation-page/standard-workspace/objects-side-bar/objects-list.tsx @@ -30,7 +30,8 @@ interface StateToProps { listHeight: number; statesHidden: boolean; statesLocked: boolean; - statesCollapsed: boolean; + statesCollapsedAll: boolean; + collapsedStates: Record; objectStates: any[]; annotationsFilters: string[]; colors: string[]; @@ -62,6 +63,7 @@ function mapStateToProps(state: CombinedState): StateToProps { filters: annotationsFilters, filtersHistory: annotationsFiltersHistory, collapsed, + collapsedAll, activatedStateID, zLayer: { min: minZLayer, @@ -95,25 +97,23 @@ function mapStateToProps(state: CombinedState): StateToProps { let statesHidden = true; let statesLocked = true; - let statesCollapsed = true; objectStates.forEach((objectState: any) => { - const { clientID, lock } = objectState; + const { lock } = objectState; if (!lock) { if (objectState.objectType !== ObjectType.TAG) { statesHidden = statesHidden && objectState.hidden; } statesLocked = statesLocked && objectState.lock; } - const stateCollapsed = clientID in collapsed ? collapsed[clientID] : true; - statesCollapsed = statesCollapsed && stateCollapsed; }); return { listHeight, statesHidden, statesLocked, - statesCollapsed, + statesCollapsedAll: collapsedAll, + collapsedStates: collapsed, objectStates, frameNumber, jobInstance, diff --git a/cvat-ui/src/containers/login-page/login-page.tsx b/cvat-ui/src/containers/login-page/login-page.tsx index fdf40ac7649c..0605d44dc749 100644 --- a/cvat-ui/src/containers/login-page/login-page.tsx +++ b/cvat-ui/src/containers/login-page/login-page.tsx @@ -9,6 +9,7 @@ import { loginAsync } from 'actions/auth-actions'; interface StateToProps { fetching: boolean; + renderResetPassword: boolean; } interface DispatchToProps { @@ -18,6 +19,7 @@ interface DispatchToProps { function mapStateToProps(state: CombinedState): StateToProps { return { fetching: state.auth.fetching, + renderResetPassword: state.auth.allowResetPassword, }; } diff --git a/cvat-ui/src/containers/model-runner-dialog/model-runner-dialog.tsx b/cvat-ui/src/containers/model-runner-dialog/model-runner-dialog.tsx index b956876ca529..3f63bd7f6a11 100644 --- a/cvat-ui/src/containers/model-runner-dialog/model-runner-dialog.tsx +++ b/cvat-ui/src/containers/model-runner-dialog/model-runner-dialog.tsx @@ -9,7 +9,8 @@ import { Model, CombinedState } from 'reducers/interfaces'; import { startInferenceAsync, modelsActions } from 'actions/models-actions'; interface StateToProps { - models: Model[]; + reid: Model[]; + detectors: Model[]; activeProcesses: { [index: string]: string; }; @@ -30,7 +31,8 @@ function mapStateToProps(state: CombinedState): StateToProps { const { models } = state; return { - models: models.models, + reid: models.reid, + detectors: models.detectors, activeProcesses: {}, taskInstance: models.activeRunTask, visible: models.visibleRunWindows, diff --git a/cvat-ui/src/containers/models-page/models-page.tsx b/cvat-ui/src/containers/models-page/models-page.tsx index 8282d41db42f..734ad0dcbce2 100644 --- a/cvat-ui/src/containers/models-page/models-page.tsx +++ b/cvat-ui/src/containers/models-page/models-page.tsx @@ -11,14 +11,26 @@ import { } from 'reducers/interfaces'; interface StateToProps { - deployedModels: Model[]; + interactors: Model[]; + detectors: Model[]; + trackers: Model[]; + reid: Model[]; } function mapStateToProps(state: CombinedState): StateToProps { const { models } = state; + const { + interactors, + detectors, + trackers, + reid, + } = models; return { - deployedModels: models.models, + interactors, + detectors, + trackers, + reid, }; } diff --git a/cvat-ui/src/cvat-canvas-wrapper.ts b/cvat-ui/src/cvat-canvas-wrapper.ts index 631a52a915f3..2dc70b8db859 100644 --- a/cvat-ui/src/cvat-canvas-wrapper.ts +++ b/cvat-ui/src/cvat-canvas-wrapper.ts @@ -8,8 +8,13 @@ import { CanvasVersion, RectDrawingMethod, CuboidDrawingMethod, + InteractionData as InteractionDataType, + InteractionResult as InteractionResultType, } from 'cvat-canvas/src/typescript/canvas'; +export type InteractionData = InteractionDataType; +export type InteractionResult = InteractionResultType; + export { Canvas, CanvasMode, diff --git a/cvat-ui/src/icons.tsx b/cvat-ui/src/icons.tsx index fbf4860723d5..a3e92e929aef 100644 --- a/cvat-ui/src/icons.tsx +++ b/cvat-ui/src/icons.tsx @@ -42,6 +42,8 @@ import SVGForegroundIcon from './assets/foreground-icon.svg'; import SVGCubeIcon from './assets/cube-icon.svg'; import SVGResetPerspectiveIcon from './assets/reset-perspective.svg'; import SVGColorizeIcon from './assets/colorize-icon.svg'; +import SVGAITools from './assets/ai-tools-icon.svg'; + export const CVATLogo = React.memo( (): JSX.Element => , @@ -154,6 +156,9 @@ export const CubeIcon = React.memo( export const ResetPerspectiveIcon = React.memo( (): JSX.Element => , ); +export const AIToolsIcon = React.memo( + (): JSX.Element => , +); export const ColorizeIcon = React.memo( (): JSX.Element => , ); diff --git a/cvat-ui/src/index.tsx b/cvat-ui/src/index.tsx index f1f7f7f17e28..ce3355c455b8 100644 --- a/cvat-ui/src/index.tsx +++ b/cvat-ui/src/index.tsx @@ -57,6 +57,7 @@ interface StateToProps { authActionsFetching: boolean; authActionsInitialized: boolean; allowChangePassword: boolean; + allowResetPassword: boolean; notifications: NotificationsState; user: any; keyMap: Record; @@ -105,6 +106,7 @@ function mapStateToProps(state: CombinedState): StateToProps { authActionsFetching: auth.authActionsFetching, authActionsInitialized: auth.authActionsInitialized, allowChangePassword: auth.allowChangePassword, + allowResetPassword: auth.allowResetPassword, notifications: state.notifications, user: auth.user, keyMap: shortcuts.keyMap, diff --git a/cvat-ui/src/reducers/annotation-reducer.ts b/cvat-ui/src/reducers/annotation-reducer.ts index 4976977dcdc6..ca9567e1221d 100644 --- a/cvat-ui/src/reducers/annotation-reducer.ts +++ b/cvat-ui/src/reducers/annotation-reducer.ts @@ -67,6 +67,7 @@ const defaultState: AnnotationState = { statuses: [], }, collapsed: {}, + collapsedAll: true, states: [], filters: [], filtersHistory: JSON.parse( @@ -352,6 +353,7 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { } = action.payload; const updatedCollapsedStates = { ...state.annotations.collapsed }; + const totalStatesCount = state.annotations.states.length; for (const objectState of states) { updatedCollapsedStates[objectState.clientID] = collapsed; } @@ -361,6 +363,8 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { annotations: { ...state.annotations, collapsed: updatedCollapsedStates, + collapsedAll: states.length === totalStatesCount + ? collapsed : state.annotations.collapsedAll, }, }; } @@ -428,6 +432,7 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { activeControl, }, drawing: { + activeInteractor: undefined, activeLabelID: labelID, activeNumOfPoints: points, activeObjectType: objectType, @@ -1039,8 +1044,30 @@ export default (state = defaultState, action: AnyAction): AnnotationState => { }, }; } + case AnnotationActionTypes.INTERACT_WITH_CANVAS: { + return { + ...state, + annotations: { + ...state.annotations, + activatedStateID: null, + }, + drawing: { + ...state.drawing, + activeInteractor: action.payload.activeInteractor, + activeLabelID: action.payload.activeLabelID, + }, + canvas: { + ...state.canvas, + activeControl: ActiveControl.INTERACTION, + }, + }; + } case AnnotationActionTypes.CHANGE_WORKSPACE: { const { workspace } = action.payload; + if (state.canvas.activeControl !== ActiveControl.CURSOR) { + return state; + } + return { ...state, workspace, diff --git a/cvat-ui/src/reducers/auth-reducer.ts b/cvat-ui/src/reducers/auth-reducer.ts index d433e7d58bd9..da424cca88fa 100644 --- a/cvat-ui/src/reducers/auth-reducer.ts +++ b/cvat-ui/src/reducers/auth-reducer.ts @@ -14,6 +14,7 @@ const defaultState: AuthState = { authActionsInitialized: false, allowChangePassword: false, showChangePasswordDialog: false, + allowResetPassword: false, }; export default function (state = defaultState, action: AuthActions | boundariesActions): AuthState { @@ -83,7 +84,6 @@ export default function (state = defaultState, action: AuthActions | boundariesA ...state, fetching: false, showChangePasswordDialog: false, - }; case AuthActionTypes.CHANGE_PASSWORD_FAILED: return { @@ -97,6 +97,36 @@ export default function (state = defaultState, action: AuthActions | boundariesA ? !state.showChangePasswordDialog : action.payload.showChangePasswordDialog, }; + case AuthActionTypes.REQUEST_PASSWORD_RESET: + return { + ...state, + fetching: true, + }; + case AuthActionTypes.REQUEST_PASSWORD_RESET_SUCCESS: + return { + ...state, + fetching: false, + }; + case AuthActionTypes.REQUEST_PASSWORD_RESET_FAILED: + return { + ...state, + fetching: false, + }; + case AuthActionTypes.RESET_PASSWORD: + return { + ...state, + fetching: true, + }; + case AuthActionTypes.RESET_PASSWORD_SUCCESS: + return { + ...state, + fetching: false, + }; + case AuthActionTypes.RESET_PASSWORD_FAILED: + return { + ...state, + fetching: false, + }; case AuthActionTypes.LOAD_AUTH_ACTIONS: return { ...state, @@ -108,6 +138,7 @@ export default function (state = defaultState, action: AuthActions | boundariesA authActionsFetching: false, authActionsInitialized: true, allowChangePassword: action.payload.allowChangePassword, + allowResetPassword: action.payload.allowResetPassword, }; case AuthActionTypes.LOAD_AUTH_ACTIONS_FAILED: return { @@ -115,6 +146,7 @@ export default function (state = defaultState, action: AuthActions | boundariesA authActionsFetching: false, authActionsInitialized: true, allowChangePassword: false, + allowResetPassword: false, }; case BoundariesActionTypes.RESET_AFTER_ERROR: { return { ...defaultState }; diff --git a/cvat-ui/src/reducers/interfaces.ts b/cvat-ui/src/reducers/interfaces.ts index fbc4b5ed9158..8f9e7af4fff9 100644 --- a/cvat-ui/src/reducers/interfaces.ts +++ b/cvat-ui/src/reducers/interfaces.ts @@ -17,6 +17,7 @@ export interface AuthState { authActionsInitialized: boolean; showChangePasswordDialog: boolean; allowChangePassword: boolean; + allowResetPassword: boolean; } export interface TasksQuery { @@ -76,7 +77,6 @@ export interface FormatsState { // eslint-disable-next-line import/prefer-default-export export enum SupportedPlugins { GIT_INTEGRATION = 'GIT_INTEGRATION', - DEXTR_SEGMENTATION = 'DEXTR_SEGMENTATION', ANALYTICS = 'ANALYTICS', } @@ -161,7 +161,10 @@ export interface ModelsState { initialized: boolean; fetching: boolean; creatingStatus: string; - models: Model[]; + interactors: Model[]; + detectors: Model[]; + trackers: Model[]; + reid: Model[]; inferences: { [index: number]: ActiveInference; }; @@ -182,6 +185,8 @@ export interface NotificationsState { logout: null | ErrorState; register: null | ErrorState; changePassword: null | ErrorState; + requestPasswordReset: null | ErrorState; + resetPassword: null | ErrorState; loadAuthActions: null | ErrorState; }; tasks: { @@ -206,9 +211,7 @@ export interface NotificationsState { fetching: null | ErrorState; }; models: { - creating: null | ErrorState; starting: null | ErrorState; - deleting: null | ErrorState; fetching: null | ErrorState; canceling: null | ErrorState; metaFetching: null | ErrorState; @@ -253,6 +256,8 @@ export interface NotificationsState { auth: { changePasswordDone: string; registerDone: string; + requestPasswordResetDone: string; + resetPasswordDone: string; }; }; } @@ -270,6 +275,7 @@ export enum ActiveControl { GROUP = 'group', SPLIT = 'split', EDIT = 'edit', + INTERACTION = 'interaction', } export enum ShapeType { @@ -342,6 +348,7 @@ export interface AnnotationState { frameAngles: number[]; }; drawing: { + activeInteractor?: Model; activeShapeType: ShapeType; activeRectDrawingMethod?: RectDrawingMethod; activeNumOfPoints?: number; @@ -354,6 +361,7 @@ export interface AnnotationState { activatedStateID: number | null; activatedAttributeID: number | null; collapsed: Record; + collapsedAll: boolean; states: any[]; filters: string[]; filtersHistory: string[]; @@ -445,7 +453,8 @@ export interface ShapesSettingsState { colorBy: ColorBy; opacity: number; selectedOpacity: number; - blackBorders: boolean; + outlined: boolean; + outlineColor: string; showBitmap: boolean; showProjections: boolean; } diff --git a/cvat-ui/src/reducers/models-reducer.ts b/cvat-ui/src/reducers/models-reducer.ts index 3e369584b5fe..18371aea02bd 100644 --- a/cvat-ui/src/reducers/models-reducer.ts +++ b/cvat-ui/src/reducers/models-reducer.ts @@ -5,13 +5,16 @@ import { boundariesActions, BoundariesActionTypes } from 'actions/boundaries-actions'; import { ModelsActionTypes, ModelsActions } from 'actions/models-actions'; import { AuthActionTypes, AuthActions } from 'actions/auth-actions'; -import { ModelsState } from './interfaces'; +import { ModelsState, Model } from './interfaces'; const defaultState: ModelsState = { initialized: false, fetching: false, creatingStatus: '', - models: [], + interactors: [], + detectors: [], + trackers: [], + reid: [], visibleRunWindows: false, activeRunTask: null, inferences: {}, @@ -32,7 +35,10 @@ export default function ( case ModelsActionTypes.GET_MODELS_SUCCESS: { return { ...state, - models: action.payload.models, + interactors: action.payload.models.filter((model: Model) => ['interactor'].includes(model.type)), + detectors: action.payload.models.filter((model: Model) => ['detector'].includes(model.type)), + trackers: action.payload.models.filter((model: Model) => ['tracker'].includes(model.type)), + reid: action.payload.models.filter((model: Model) => ['reid'].includes(model.type)), initialized: true, fetching: false, }; diff --git a/cvat-ui/src/reducers/notifications-reducer.ts b/cvat-ui/src/reducers/notifications-reducer.ts index dfbfdbf5432b..96863e6bc3f0 100644 --- a/cvat-ui/src/reducers/notifications-reducer.ts +++ b/cvat-ui/src/reducers/notifications-reducer.ts @@ -27,6 +27,8 @@ const defaultState: NotificationsState = { logout: null, register: null, changePassword: null, + requestPasswordReset: null, + resetPassword: null, loadAuthActions: null, }, tasks: { @@ -51,9 +53,7 @@ const defaultState: NotificationsState = { fetching: null, }, models: { - creating: null, starting: null, - deleting: null, fetching: null, canceling: null, metaFetching: null, @@ -98,6 +98,8 @@ const defaultState: NotificationsState = { auth: { changePasswordDone: '', registerDone: '', + requestPasswordResetDone: '', + resetPasswordDone: '', }, }, }; @@ -210,6 +212,61 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } + case AuthActionTypes.REQUEST_PASSWORD_RESET_SUCCESS: { + return { + ...state, + messages: { + ...state.messages, + auth: { + ...state.messages.auth, + requestPasswordResetDone: `Check your email for a link to reset your password. + If it doesn’t appear within a few minutes, check your spam folder.`, + }, + }, + }; + } + case AuthActionTypes.REQUEST_PASSWORD_RESET_FAILED: { + return { + ...state, + errors: { + ...state.errors, + auth: { + ...state.errors.auth, + requestPasswordReset: { + message: 'Could not reset password on the server.', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } + case AuthActionTypes.RESET_PASSWORD_SUCCESS: { + return { + ...state, + messages: { + ...state.messages, + auth: { + ...state.messages.auth, + resetPasswordDone: 'Password has been reset with the new password.', + }, + }, + }; + } + case AuthActionTypes.RESET_PASSWORD_FAILED: { + return { + ...state, + errors: { + ...state.errors, + auth: { + ...state.errors.auth, + resetPassword: { + message: 'Could not set new password on the server.', + reason: action.payload.error.toString(), + }, + }, + }, + }; + } case AuthActionTypes.LOAD_AUTH_ACTIONS_FAILED: { return { ...state, @@ -414,21 +471,6 @@ export default function (state = defaultState, action: AnyAction): Notifications }, }; } - case ModelsActionTypes.CREATE_MODEL_FAILED: { - return { - ...state, - errors: { - ...state.errors, - models: { - ...state.errors.models, - creating: { - message: 'Could not create the model', - reason: action.payload.error.toString(), - }, - }, - }, - }; - } case ModelsActionTypes.GET_INFERENCE_STATUS_SUCCESS: { if (action.payload.activeInference.status === 'finished') { const { taskID } = action.payload; diff --git a/cvat-ui/src/reducers/plugins-reducer.ts b/cvat-ui/src/reducers/plugins-reducer.ts index 71cfc3aba2a9..b18db9871c69 100644 --- a/cvat-ui/src/reducers/plugins-reducer.ts +++ b/cvat-ui/src/reducers/plugins-reducer.ts @@ -4,7 +4,6 @@ import { PluginsActionTypes, PluginActions } from 'actions/plugins-actions'; import { registerGitPlugin } from 'utils/git-utils'; -import { registerDEXTRPlugin } from 'utils/dextr-utils'; import { PluginsState } from './interfaces'; const defaultState: PluginsState = { @@ -12,7 +11,6 @@ const defaultState: PluginsState = { initialized: false, list: { GIT_INTEGRATION: false, - DEXTR_SEGMENTATION: false, ANALYTICS: false, }, }; @@ -36,10 +34,6 @@ export default function ( registerGitPlugin(); } - if (!state.list.DEXTR_SEGMENTATION && list.DEXTR_SEGMENTATION) { - registerDEXTRPlugin(); - } - return { ...state, initialized: true, diff --git a/cvat-ui/src/reducers/settings-reducer.ts b/cvat-ui/src/reducers/settings-reducer.ts index eb4fd7555552..bb71a700a580 100644 --- a/cvat-ui/src/reducers/settings-reducer.ts +++ b/cvat-ui/src/reducers/settings-reducer.ts @@ -21,7 +21,8 @@ const defaultState: SettingsState = { colorBy: ColorBy.LABEL, opacity: 3, selectedOpacity: 30, - blackBorders: false, + outlined: false, + outlineColor: '#000000', showBitmap: false, showProjections: false, }, @@ -124,12 +125,13 @@ export default (state = defaultState, action: AnyAction): SettingsState => { }, }; } - case SettingsActionTypes.CHANGE_SHAPES_BLACK_BORDERS: { + case SettingsActionTypes.CHANGE_SHAPES_OUTLINED_BORDERS: { return { ...state, shapes: { ...state.shapes, - blackBorders: action.payload.blackBorders, + outlined: action.payload.outlined, + outlineColor: action.payload.color, }, }; } diff --git a/cvat-ui/src/utils/dextr-utils.ts b/cvat-ui/src/utils/dextr-utils.ts deleted file mode 100644 index a9d92abfffd2..000000000000 --- a/cvat-ui/src/utils/dextr-utils.ts +++ /dev/null @@ -1,214 +0,0 @@ -// Copyright (C) 2020 Intel Corporation -// -// SPDX-License-Identifier: MIT - -import getCore from 'cvat-core-wrapper'; -import { Canvas } from 'cvat-canvas-wrapper'; -import { ShapeType, CombinedState } from 'reducers/interfaces'; -import { getCVATStore } from 'cvat-store'; - -const core = getCore(); - -interface DEXTRPlugin { - name: string; - description: string; - cvat: { - classes: { - Job: { - prototype: { - annotations: { - put: { - enter(self: any, objects: any[]): Promise; - }; - }; - }; - }; - }; - }; - data: { - canceled: boolean; - enabled: boolean; - }; -} - -interface Point { - x: number; - y: number; -} - -const antModalRoot = document.createElement('div'); -const antModalMask = document.createElement('div'); -antModalMask.classList.add('ant-modal-mask'); -const antModalWrap = document.createElement('div'); -antModalWrap.classList.add('ant-modal-wrap'); -antModalWrap.setAttribute('role', 'dialog'); -const antModal = document.createElement('div'); -antModal.classList.add('ant-modal'); -antModal.style.width = '300px'; -antModal.style.top = '40%'; -antModal.setAttribute('role', 'document'); -const antModalContent = document.createElement('div'); -antModalContent.classList.add('ant-modal-content'); -const antModalBody = document.createElement('div'); -antModalBody.classList.add('ant-modal-body'); -antModalBody.style.textAlign = 'center'; -const antModalSpan = document.createElement('span'); -antModalSpan.innerText = 'Segmentation request is being processed'; -antModalSpan.style.display = 'block'; -const antModalButton = document.createElement('button'); -antModalButton.disabled = true; -antModalButton.classList.add('ant-btn', 'ant-btn-primary'); -antModalButton.style.width = '100px'; -antModalButton.style.margin = '10px auto'; -const antModalButtonSpan = document.createElement('span'); -antModalButtonSpan.innerText = 'Cancel'; - -antModalBody.append(antModalSpan, antModalButton); -antModalButton.append(antModalButtonSpan); -antModalContent.append(antModalBody); -antModal.append(antModalContent); -antModalWrap.append(antModal); -antModalRoot.append(antModalMask, antModalWrap); - -async function serverRequest( - taskInstance: any, - frame: number, - points: number[], -): Promise { - const reducer = (acc: number[][], - _: number, index: number, - array: number[]): number[][] => { - if (!(index % 2)) { // 0, 2, 4 - acc.push([ - array[index], - array[index + 1], - ]); - } - return acc; - }; - - const reducedPoints = points.reduce(reducer, []); - const models = await core.lambda.list(); - const model = models.filter((func: any): boolean => func.id === 'openvino.dextr')[0]; - const result = await core.lambda.call(taskInstance, model, { - task: taskInstance, - frame, - points: reducedPoints, - }); - - return result.flat(); -} - -async function enter(this: any, self: DEXTRPlugin, objects: any[]): Promise { - try { - if (self.data.enabled && objects.length === 1) { - const state = (getCVATStore().getState() as CombinedState); - const isPolygon = state.annotation - .drawing.activeShapeType === ShapeType.POLYGON; - if (!isPolygon) return; - - document.body.append(antModalRoot); - const promises: Record> = {}; - for (let i = 0; i < objects.length; i++) { - if (objects[i].points.length >= 8) { - promises[i] = serverRequest( - this.task, - objects[i].frame, - objects[i].points, - ); - } else { - promises[i] = new Promise((resolve) => { - resolve(objects[i].points); - }); - } - } - - const transformed = await Promise - .all(Object.values(promises)); - for (let i = 0; i < objects.length; i++) { - // eslint-disable-next-line no-param-reassign - objects[i] = new core.classes.ObjectState({ - frame: objects[i].frame, - objectType: objects[i].objectType, - label: objects[i].label, - shapeType: ShapeType.POLYGON, - points: transformed[i], - occluded: objects[i].occluded, - zOrder: objects[i].zOrder, - }); - } - } - - return; - } catch (error) { - throw new core.exceptions.PluginError(error.toString()); - } finally { - // eslint-disable-next-line no-param-reassign - self.data.canceled = false; - antModalButton.disabled = true; - if (antModalRoot.parentElement === document.body) { - document.body.removeChild(antModalRoot); - } - } -} - -const plugin: DEXTRPlugin = { - name: 'Deep extreme cut', - description: 'Plugin allows to get a polygon from extreme points using AI', - cvat: { - classes: { - Job: { - prototype: { - annotations: { - put: { - enter, - }, - }, - }, - }, - }, - }, - data: { - canceled: false, - enabled: false, - }, -}; - - -antModalButton.onclick = () => { - plugin.data.canceled = true; -}; - -export function activate(canvasInstance: Canvas): void { - if (!plugin.data.enabled) { - // eslint-disable-next-line no-param-reassign - canvasInstance.draw = (drawData: any): void => { - if (drawData.enabled && drawData.shapeType === ShapeType.POLYGON - && (typeof (drawData.numberOfPoints) === 'undefined' || drawData.numberOfPoints >= 4) - && (typeof (drawData.initialState) === 'undefined') - ) { - const patchedData = { ...drawData }; - patchedData.shapeType = ShapeType.POINTS; - patchedData.crosshair = true; - Object.getPrototypeOf(canvasInstance) - .draw.call(canvasInstance, patchedData); - } else { - Object.getPrototypeOf(canvasInstance) - .draw.call(canvasInstance, drawData); - } - }; - plugin.data.enabled = true; - } -} - -export function deactivate(canvasInstance: Canvas): void { - if (plugin.data.enabled) { - // eslint-disable-next-line no-param-reassign - canvasInstance.draw = Object.getPrototypeOf(canvasInstance).draw; - plugin.data.enabled = false; - } -} - -export function registerDEXTRPlugin(): void { - core.plugins.register(plugin); -} diff --git a/cvat-ui/src/utils/plugin-checker.ts b/cvat-ui/src/utils/plugin-checker.ts index 38c29575e793..e41506106936 100644 --- a/cvat-ui/src/utils/plugin-checker.ts +++ b/cvat-ui/src/utils/plugin-checker.ts @@ -17,14 +17,6 @@ class PluginChecker { case SupportedPlugins.GIT_INTEGRATION: { return isReachable(`${serverHost}/git/repository/meta/get`, 'OPTIONS'); } - case SupportedPlugins.DEXTR_SEGMENTATION: { - try { - const list = await core.lambda.list(); - return list.map((func: any): boolean => func.id).includes('openvino.dextr'); - } catch (_) { - return false; - } - } case SupportedPlugins.ANALYTICS: { return isReachable(`${serverHost}/analytics/app/kibana`, 'GET'); } diff --git a/cvat/__init__.py b/cvat/__init__.py index 36c99d3d6a50..f96a17247d20 100644 --- a/cvat/__init__.py +++ b/cvat/__init__.py @@ -4,6 +4,6 @@ from cvat.utils.version import get_version -VERSION = (1, 1, 0, 'rc', 0) +VERSION = (1, 2, 0, 'alpha', 0) __version__ = get_version(VERSION) diff --git a/cvat/apps/authentication/serializers.py b/cvat/apps/authentication/serializers.py index 1269b8fcb72a..e04ca18ba2b7 100644 --- a/cvat/apps/authentication/serializers.py +++ b/cvat/apps/authentication/serializers.py @@ -1,16 +1,31 @@ from rest_auth.registration.serializers import RegisterSerializer +from rest_auth.serializers import PasswordResetSerializer from rest_framework import serializers +from django.conf import settings + class RegisterSerializerEx(RegisterSerializer): - first_name = serializers.CharField(required=False) - last_name = serializers.CharField(required=False) + first_name = serializers.CharField(required=False) + last_name = serializers.CharField(required=False) + + def get_cleaned_data(self): + data = super().get_cleaned_data() + data.update({ + 'first_name': self.validated_data.get('first_name', ''), + 'last_name': self.validated_data.get('last_name', ''), + }) - def get_cleaned_data(self): - data = super().get_cleaned_data() - data.update({ - 'first_name': self.validated_data.get('first_name', ''), - 'last_name': self.validated_data.get('last_name', ''), - }) + return data - return data +class PasswordResetSerializerEx(PasswordResetSerializer): + def get_email_options(self): + domain = None + if hasattr(settings, 'UI_HOST') and settings.UI_HOST: + domain = settings.UI_HOST + if hasattr(settings, 'UI_PORT') and settings.UI_PORT: + domain += ':{}'.format(settings.UI_PORT) + return { + 'email_template_name': 'authentication/password_reset_email.html', + 'domain_override': domain + } diff --git a/cvat/apps/authentication/templates/authentication/password_reset_email.html b/cvat/apps/authentication/templates/authentication/password_reset_email.html new file mode 100644 index 000000000000..542e266a0770 --- /dev/null +++ b/cvat/apps/authentication/templates/authentication/password_reset_email.html @@ -0,0 +1,14 @@ +{% load i18n %}{% autoescape off %} +{% blocktrans %}You're receiving this email because you requested a password reset for your user account at {{ site_name }}.{% endblocktrans %} + +{% trans "Please go to the following page and choose a new password:" %} +{% block reset_link %} +{{ protocol }}://{{ domain }}/auth/password/reset/confirm?uid={{ uid }}&token={{ token }} +{% endblock %} +{% trans "Your username, in case you've forgotten:" %} {{ user.get_username }} + +{% trans "Thanks for using our site!" %} + +{% blocktrans %}The {{ site_name }} team{% endblocktrans %} + +{% endautoescape %} diff --git a/cvat/apps/engine/cache.py b/cvat/apps/engine/cache.py new file mode 100644 index 000000000000..2f8b0a628c35 --- /dev/null +++ b/cvat/apps/engine/cache.py @@ -0,0 +1,57 @@ +# Copyright (C) 2020 Intel Corporation +# +# SPDX-License-Identifier: MIT + +from diskcache import Cache +from django.conf import settings +from cvat.apps.engine.media_extractors import (Mpeg4ChunkWriter, ZipChunkWriter, + Mpeg4CompressedChunkWriter, ZipCompressedChunkWriter) +from cvat.apps.engine.models import DataChoice +from .prepare import PrepareInfo +import os +from io import BytesIO + +class CacheInteraction: + def __init__(self): + self._cache = Cache(settings.CACHE_ROOT) + + def __del__(self): + self._cache.close() + + def get_buff_mime(self, chunk_number, quality, db_data): + chunk, tag = self._cache.get('{}_{}_{}'.format(db_data.id, chunk_number, quality), tag=True) + + if not chunk: + chunk, tag = self.prepare_chunk_buff(db_data, quality, chunk_number) + self.save_chunk(db_data.id, chunk_number, quality, chunk, tag) + return chunk, tag + + def prepare_chunk_buff(self, db_data, quality, chunk_number): + from cvat.apps.engine.frame_provider import FrameProvider + extractor_classes = { + FrameProvider.Quality.COMPRESSED : Mpeg4CompressedChunkWriter if db_data.compressed_chunk_type == DataChoice.VIDEO else ZipCompressedChunkWriter, + FrameProvider.Quality.ORIGINAL : Mpeg4ChunkWriter if db_data.original_chunk_type == DataChoice.VIDEO else ZipChunkWriter, + } + + image_quality = 100 if extractor_classes[quality] in [Mpeg4ChunkWriter, ZipChunkWriter] else db_data.image_quality + mime_type = 'video/mp4' if extractor_classes[quality] in [Mpeg4ChunkWriter, Mpeg4CompressedChunkWriter] else 'application/zip' + + extractor = extractor_classes[quality](image_quality) + + images = [] + buff = BytesIO() + if os.path.exists(db_data.get_meta_path()): + source_path = os.path.join(db_data.get_upload_dirname(), db_data.video.path) + meta = PrepareInfo(source_path=source_path, meta_path=db_data.get_meta_path()) + for frame in meta.decode_needed_frames(chunk_number, db_data): + images.append(frame) + extractor.save_as_chunk([(image, source_path, None) for image in images], buff) + else: + with open(db_data.get_dummy_chunk_path(chunk_number), 'r') as dummy_file: + images = [os.path.join(db_data.get_upload_dirname(), line.strip()) for line in dummy_file] + extractor.save_as_chunk([(image, image, None) for image in images], buff) + buff.seek(0) + return buff, mime_type + + def save_chunk(self, db_data_id, chunk_number, quality, buff, mime_type): + self._cache.set('{}_{}_{}'.format(db_data_id, chunk_number, quality), buff, tag=mime_type) \ No newline at end of file diff --git a/cvat/apps/engine/frame_provider.py b/cvat/apps/engine/frame_provider.py index 25575ea51d36..8f56463a45f7 100644 --- a/cvat/apps/engine/frame_provider.py +++ b/cvat/apps/engine/frame_provider.py @@ -11,8 +11,8 @@ from cvat.apps.engine.media_extractors import VideoReader, ZipReader from cvat.apps.engine.mime_types import mimetypes -from cvat.apps.engine.models import DataChoice - +from cvat.apps.engine.models import DataChoice, StorageMethodChoice +from .cache import CacheInteraction class RandomAccessIterator: def __init__(self, iterable): @@ -65,6 +65,19 @@ def load(self, chunk_id): self.reader_class([self.get_chunk_path(chunk_id)])) return self.chunk_reader + class BuffChunkLoader(ChunkLoader): + def __init__(self, reader_class, path_getter, quality, db_data): + super().__init__(reader_class, path_getter) + self.quality = quality + self.db_data = db_data + + def load(self, chunk_id): + if self.chunk_id != chunk_id: + self.chunk_id = chunk_id + self.chunk_reader = RandomAccessIterator( + self.reader_class([self.get_chunk_path(chunk_id, self.quality, self.db_data)[0]])) + return self.chunk_reader + def __init__(self, db_data): self._db_data = db_data self._loaders = {} @@ -73,12 +86,27 @@ def __init__(self, db_data): DataChoice.IMAGESET: ZipReader, DataChoice.VIDEO: VideoReader, } - self._loaders[self.Quality.COMPRESSED] = self.ChunkLoader( - reader_class[db_data.compressed_chunk_type], - db_data.get_compressed_chunk_path) - self._loaders[self.Quality.ORIGINAL] = self.ChunkLoader( - reader_class[db_data.original_chunk_type], - db_data.get_original_chunk_path) + + if db_data.storage_method == StorageMethodChoice.CACHE: + cache = CacheInteraction() + + self._loaders[self.Quality.COMPRESSED] = self.BuffChunkLoader( + reader_class[db_data.compressed_chunk_type], + cache.get_buff_mime, + self.Quality.COMPRESSED, + self._db_data) + self._loaders[self.Quality.ORIGINAL] = self.BuffChunkLoader( + reader_class[db_data.original_chunk_type], + cache.get_buff_mime, + self.Quality.ORIGINAL, + self._db_data) + else: + self._loaders[self.Quality.COMPRESSED] = self.ChunkLoader( + reader_class[db_data.compressed_chunk_type], + db_data.get_compressed_chunk_path) + self._loaders[self.Quality.ORIGINAL] = self.ChunkLoader( + reader_class[db_data.original_chunk_type], + db_data.get_original_chunk_path) def __len__(self): return self._db_data.size @@ -129,6 +157,8 @@ def get_preview(self): def get_chunk(self, chunk_number, quality=Quality.ORIGINAL): chunk_number = self._validate_chunk_number(chunk_number) + if self._db_data.storage_method == StorageMethodChoice.CACHE: + return self._loaders[quality].get_chunk_path(chunk_number, quality, self._db_data) return self._loaders[quality].get_chunk_path(chunk_number) def get_frame(self, frame_number, quality=Quality.ORIGINAL, diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py index dea14183a10b..08a660c5d68a 100644 --- a/cvat/apps/engine/media_extractors.py +++ b/cvat/apps/engine/media_extractors.py @@ -125,22 +125,17 @@ def __init__(self, source_path, step=1, start=0, stop=None): class ArchiveReader(DirectoryReader): def __init__(self, source_path, step=1, start=0, stop=None): - self._tmp_dir = create_tmp_dir() self._archive_source = source_path[0] - Archive(self._archive_source).extractall(self._tmp_dir) + Archive(self._archive_source).extractall(os.path.dirname(source_path[0])) super().__init__( - source_path=[self._tmp_dir], + source_path=[os.path.dirname(source_path[0])], step=step, start=start, stop=stop, ) def __del__(self): - delete_tmp_dir(self._tmp_dir) - - def get_path(self, i): - base_dir = os.path.dirname(self._archive_source) - return os.path.join(base_dir, os.path.relpath(self._source_path[i], self._tmp_dir)) + os.remove(self._archive_source) class PdfReader(DirectoryReader): def __init__(self, source_path, step=1, start=0, stop=None): @@ -191,7 +186,14 @@ def get_image(self, i): return io.BytesIO(self._zip_source.read(self._source_path[i])) def get_path(self, i): - return os.path.join(os.path.dirname(self._zip_source.filename), self._source_path[i]) + if self._zip_source.filename: + return os.path.join(os.path.dirname(self._zip_source.filename), self._source_path[i]) + else: # necessary for mime_type definition + return self._source_path[i] + + def extract(self): + self._zip_source.extractall(os.path.dirname(self._zip_source.filename)) + os.remove(self._zip_source.filename) class VideoReader(IMediaReader): def __init__(self, source_path, step=1, start=0, stop=None): @@ -303,14 +305,14 @@ def __init__(self, _): self._output_fps = 25 @staticmethod - def _create_av_container(path, w, h, rate, options): + def _create_av_container(path, w, h, rate, options, f='mp4'): # x264 requires width and height must be divisible by 2 for yuv420p if h % 2: h += 1 if w % 2: w += 1 - container = av.open(path, 'w') + container = av.open(path, 'w',format=f) video_stream = container.add_stream('libx264', rate=rate) video_stream.pix_fmt = "yuv420p" video_stream.width = w diff --git a/cvat/apps/engine/migrations/0029_data_storage_method.py b/cvat/apps/engine/migrations/0029_data_storage_method.py new file mode 100644 index 000000000000..1c1aa814e4cd --- /dev/null +++ b/cvat/apps/engine/migrations/0029_data_storage_method.py @@ -0,0 +1,36 @@ +# Generated by Django 2.2.13 on 2020-08-13 05:49 + +from cvat.apps.engine.media_extractors import _is_archive, _is_zip +import cvat.apps.engine.models +from django.conf import settings +from django.db import migrations, models +import os +from pyunpack import Archive + +def unzip(apps, schema_editor): + Data = apps.get_model("engine", "Data") + data_q_set = Data.objects.all() + archive_paths = [] + + for data_instance in data_q_set: + for root, _, files in os.walk(os.path.join(settings.MEDIA_DATA_ROOT, '{}/raw/'.format(data_instance.id))): + archive_paths.extend([os.path.join(root, file) for file in files if _is_archive(file) or _is_zip(file)]) + + for path in archive_paths: + Archive(path).extractall(os.path.dirname(path)) + os.remove(path) + +class Migration(migrations.Migration): + + dependencies = [ + ('engine', '0028_labelcolor'), + ] + + operations = [ + migrations.AddField( + model_name='data', + name='storage_method', + field=models.CharField(choices=[('cache', 'CACHE'), ('file_system', 'FILE_SYSTEM')], default=cvat.apps.engine.models.StorageMethodChoice('file_system'), max_length=15), + ), + migrations.RunPython(unzip), + ] diff --git a/cvat/apps/engine/models.py b/cvat/apps/engine/models.py index f3fd13b420e9..152fa4fa7d99 100644 --- a/cvat/apps/engine/models.py +++ b/cvat/apps/engine/models.py @@ -43,6 +43,17 @@ def choices(cls): def __str__(self): return self.value +class StorageMethodChoice(str, Enum): + CACHE = 'cache' + FILE_SYSTEM = 'file_system' + + @classmethod + def choices(cls): + return tuple((x.value, x.name) for x in cls) + + def __str__(self): + return self.value + class Data(models.Model): chunk_size = models.PositiveIntegerField(null=True) size = models.PositiveIntegerField(default=0) @@ -54,6 +65,7 @@ class Data(models.Model): default=DataChoice.IMAGESET) original_chunk_type = models.CharField(max_length=32, choices=DataChoice.choices(), default=DataChoice.IMAGESET) + storage_method = models.CharField(max_length=15, choices=StorageMethodChoice.choices(), default=StorageMethodChoice.FILE_SYSTEM) class Meta: default_permissions = () @@ -102,6 +114,12 @@ def get_compressed_chunk_path(self, chunk_number): def get_preview_path(self): return os.path.join(self.get_data_dirname(), 'preview.jpeg') + def get_meta_path(self): + return os.path.join(self.get_upload_dirname(), 'meta_info.txt') + + def get_dummy_chunk_path(self, chunk_number): + return os.path.join(self.get_upload_dirname(), 'dummy_{}.txt'.format(chunk_number)) + class Video(models.Model): data = models.OneToOneField(Data, on_delete=models.CASCADE, related_name="video", null=True) path = models.CharField(max_length=1024, default='') diff --git a/cvat/apps/engine/prepare.py b/cvat/apps/engine/prepare.py new file mode 100644 index 000000000000..3d4ca7dabd9b --- /dev/null +++ b/cvat/apps/engine/prepare.py @@ -0,0 +1,155 @@ +# Copyright (C) 2020 Intel Corporation +# +# SPDX-License-Identifier: MIT + +import av +import hashlib + +class WorkWithVideo: + def __init__(self, **kwargs): + if not kwargs.get('source_path'): + raise Exception('No sourse path') + self.source_path = kwargs.get('source_path') + + def _open_video_container(self, sourse_path, mode, options=None): + return av.open(sourse_path, mode=mode, options=options) + + def _close_video_container(self, container): + container.close() + + def _get_video_stream(self, container): + video_stream = next(stream for stream in container.streams if stream.type == 'video') + video_stream.thread_type = 'AUTO' + return video_stream + + +class AnalyzeVideo(WorkWithVideo): + def check_type_first_frame(self): + container = self._open_video_container(self.source_path, mode='r') + video_stream = self._get_video_stream(container) + + for packet in container.demux(video_stream): + for frame in packet.decode(): + self._close_video_container(container) + assert frame.pict_type.name == 'I', 'First frame is not key frame' + return + + def check_video_timestamps_sequences(self): + container = self._open_video_container(self.source_path, mode='r') + video_stream = self._get_video_stream(container) + + frame_pts = -1 + frame_dts = -1 + for packet in container.demux(video_stream): + for frame in packet.decode(): + + if None not in [frame.pts, frame_pts] and frame.pts <= frame_pts: + self._close_video_container(container) + raise Exception('Invalid pts sequences') + + if None not in [frame.dts, frame_dts] and frame.dts <= frame_dts: + self._close_video_container(container) + raise Exception('Invalid dts sequences') + + frame_pts, frame_dts = frame.pts, frame.dts + self._close_video_container(container) + +def md5_hash(frame): + return hashlib.md5(frame.to_image().tobytes()).hexdigest() + +class PrepareInfo(WorkWithVideo): + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + if not kwargs.get('meta_path'): + raise Exception('No meta path') + + self.meta_path = kwargs.get('meta_path') + self.key_frames = {} + self.frames = 0 + + def get_task_size(self): + return self.frames + + def check_seek_key_frames(self): + container = self._open_video_container(self.source_path, mode='r') + video_stream = self._get_video_stream(container) + + key_frames_copy = self.key_frames.copy() + + for index, key_frame in key_frames_copy.items(): + container.seek(offset=key_frame.pts, stream=video_stream) + flag = True + for packet in container.demux(video_stream): + for frame in packet.decode(): + if md5_hash(frame) != md5_hash(key_frame) or frame.pts != key_frame.pts: + self.key_frames.pop(index) + flag = False + break + if not flag: + break + + #TODO: correct ratio of number of frames to keyframes + if len(self.key_frames) == 0: + raise Exception('Too few keyframes') + + def save_key_frames(self): + container = self._open_video_container(self.source_path, mode='r') + video_stream = self._get_video_stream(container) + frame_number = 0 + + for packet in container.demux(video_stream): + for frame in packet.decode(): + if frame.key_frame: + self.key_frames[frame_number] = frame + frame_number += 1 + + self.frames = frame_number + self._close_video_container(container) + + def save_meta_info(self): + with open(self.meta_path, 'w') as meta_file: + for index, frame in self.key_frames.items(): + meta_file.write('{} {}\n'.format(index, frame.pts)) + + def get_nearest_left_key_frame(self, start_chunk_frame_number): + start_decode_frame_number = 0 + start_decode_timestamp = 0 + + with open(self.meta_path, 'r') as file: + for line in file: + frame_number, timestamp = line.strip().split(' ') + + if int(frame_number) <= start_chunk_frame_number: + start_decode_frame_number = frame_number + start_decode_timestamp = timestamp + else: + break + + return int(start_decode_frame_number), int(start_decode_timestamp) + + def decode_needed_frames(self, chunk_number, db_data): + step = db_data.get_frame_step() + start_chunk_frame_number = db_data.start_frame + chunk_number * db_data.chunk_size * step + end_chunk_frame_number = min(start_chunk_frame_number + (db_data.chunk_size - 1) * step + 1, db_data.stop_frame + 1) + start_decode_frame_number, start_decode_timestamp = self.get_nearest_left_key_frame(start_chunk_frame_number) + container = self._open_video_container(self.source_path, mode='r') + video_stream = self._get_video_stream(container) + container.seek(offset=start_decode_timestamp, stream=video_stream) + + frame_number = start_decode_frame_number - 1 + for packet in container.demux(video_stream): + for frame in packet.decode(): + frame_number += 1 + if frame_number < start_chunk_frame_number: + continue + elif frame_number < end_chunk_frame_number and not ((frame_number - start_chunk_frame_number) % step): + yield frame + elif (frame_number - start_chunk_frame_number) % step: + continue + else: + self._close_video_container(container) + return + + self._close_video_container(container) \ No newline at end of file diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py index f211d28b2b1d..bf31da29b1ea 100644 --- a/cvat/apps/engine/serializers.py +++ b/cvat/apps/engine/serializers.py @@ -170,11 +170,13 @@ class DataSerializer(serializers.ModelSerializer): client_files = ClientFileSerializer(many=True, default=[]) server_files = ServerFileSerializer(many=True, default=[]) remote_files = RemoteFileSerializer(many=True, default=[]) + use_cache = serializers.BooleanField(default=False) class Meta: model = models.Data fields = ('chunk_size', 'size', 'image_quality', 'start_frame', 'stop_frame', 'frame_filter', - 'compressed_chunk_type', 'original_chunk_type', 'client_files', 'server_files', 'remote_files', 'use_zip_chunks') + 'compressed_chunk_type', 'original_chunk_type', 'client_files', 'server_files', 'remote_files', 'use_zip_chunks', + 'use_cache') # pylint: disable=no-self-use def validate_frame_filter(self, value): @@ -202,6 +204,7 @@ def create(self, validated_data): server_files = validated_data.pop('server_files') remote_files = validated_data.pop('remote_files') validated_data.pop('use_zip_chunks') + validated_data.pop('use_cache') db_data = models.Data.objects.create(**validated_data) data_path = db_data.get_data_dirname() diff --git a/cvat/apps/engine/task.py b/cvat/apps/engine/task.py index 998bc4af829b..1056e2f3bc6d 100644 --- a/cvat/apps/engine/task.py +++ b/cvat/apps/engine/task.py @@ -14,7 +14,7 @@ from urllib import request as urlrequest from cvat.apps.engine.media_extractors import get_mime, MEDIA_TYPES, Mpeg4ChunkWriter, ZipChunkWriter, Mpeg4CompressedChunkWriter, ZipCompressedChunkWriter -from cvat.apps.engine.models import DataChoice +from cvat.apps.engine.models import DataChoice, StorageMethodChoice from cvat.apps.engine.utils import av_scan_paths import django_rq @@ -24,6 +24,7 @@ from . import models from .log import slogger +from .prepare import PrepareInfo, AnalyzeVideo ############################# Low Level server API @@ -243,6 +244,8 @@ def _create_thread(tid, data): start=db_data.start_frame, stop=data['stop_frame'], ) + if extractor.__class__ == MEDIA_TYPES['zip']['extractor']: + extractor.extract() db_task.mode = task_mode db_data.compressed_chunk_type = models.DataChoice.VIDEO if task_mode == 'interpolation' and not data['use_zip_chunks'] else models.DataChoice.IMAGESET db_data.original_chunk_type = models.DataChoice.VIDEO if task_mode == 'interpolation' else models.DataChoice.IMAGESET @@ -276,37 +279,94 @@ def update_progress(progress): else: db_data.chunk_size = 36 + video_path = "" video_size = (0, 0) - counter = itertools.count() - generator = itertools.groupby(extractor, lambda x: next(counter) // db_data.chunk_size) - for chunk_idx, chunk_data in generator: - chunk_data = list(chunk_data) - original_chunk_path = db_data.get_original_chunk_path(chunk_idx) - original_chunk_writer.save_as_chunk(chunk_data, original_chunk_path) - - compressed_chunk_path = db_data.get_compressed_chunk_path(chunk_idx) - img_sizes = compressed_chunk_writer.save_as_chunk(chunk_data, compressed_chunk_path) - - if db_task.mode == 'annotation': - db_images.extend([ - models.Image( - data=db_data, - path=os.path.relpath(data[1], upload_dir), - frame=data[2], - width=size[0], - height=size[1]) - - for data, size in zip(chunk_data, img_sizes) - ]) - else: - video_size = img_sizes[0] - video_path = chunk_data[0][1] + if settings.USE_CACHE and db_data.storage_method == StorageMethodChoice.CACHE: + for media_type, media_files in media.items(): + if media_files: + if task_mode == MEDIA_TYPES['video']['mode']: + try: + analyzer = AnalyzeVideo(source_path=os.path.join(upload_dir, media_files[0])) + analyzer.check_type_first_frame() + analyzer.check_video_timestamps_sequences() + + meta_info = PrepareInfo(source_path=os.path.join(upload_dir, media_files[0]), + meta_path=os.path.join(upload_dir, 'meta_info.txt')) + meta_info.save_key_frames() + meta_info.check_seek_key_frames() + meta_info.save_meta_info() + + all_frames = meta_info.get_task_size() + db_data.size = len(range(db_data.start_frame, min(data['stop_frame'] + 1 if data['stop_frame'] else all_frames, all_frames), db_data.get_frame_step())) + video_path = os.path.join(upload_dir, media_files[0]) + frame = meta_info.key_frames.get(next(iter(meta_info.key_frames))) + video_size = (frame.width, frame.height) + + except Exception: + db_data.storage_method = StorageMethodChoice.FILE_SYSTEM + + else:#images,archive + counter_ = itertools.count() + if isinstance(extractor, MEDIA_TYPES['archive']['extractor']): + media_files = [os.path.relpath(path, upload_dir) for path in extractor._source_path] + elif isinstance(extractor, MEDIA_TYPES['zip']['extractor']): + media_files = extractor._source_path + + numbers_sequence = range(db_data.start_frame, min(data['stop_frame'] if data['stop_frame'] else len(media_files), len(media_files)), db_data.get_frame_step()) + m_paths = [] + m_paths = [(path, numb) for numb, path in enumerate(sorted(media_files)) if numb in numbers_sequence] + + for chunk_number, media_paths in itertools.groupby(m_paths, lambda x: next(counter_) // db_data.chunk_size): + media_paths = list(media_paths) + img_sizes = [] + from PIL import Image + with open(db_data.get_dummy_chunk_path(chunk_number), 'w') as dummy_chunk: + for path, _ in media_paths: + dummy_chunk.write(path+'\n') + img_sizes += [Image.open(os.path.join(upload_dir, path)).size] + + db_data.size += len(media_paths) + db_images.extend([ + models.Image( + data=db_data, + path=data[0], + frame=data[1], + width=size[0], + height=size[1]) + for data, size in zip(media_paths, img_sizes) + ]) + + if db_data.storage_method == StorageMethodChoice.FILE_SYSTEM or not settings.USE_CACHE: + counter = itertools.count() + generator = itertools.groupby(extractor, lambda x: next(counter) // db_data.chunk_size) + for chunk_idx, chunk_data in generator: + chunk_data = list(chunk_data) + original_chunk_path = db_data.get_original_chunk_path(chunk_idx) + original_chunk_writer.save_as_chunk(chunk_data, original_chunk_path) + + compressed_chunk_path = db_data.get_compressed_chunk_path(chunk_idx) + img_sizes = compressed_chunk_writer.save_as_chunk(chunk_data, compressed_chunk_path) + + if db_task.mode == 'annotation': + db_images.extend([ + models.Image( + data=db_data, + path=os.path.relpath(data[1], upload_dir), + frame=data[2], + width=size[0], + height=size[1]) + + for data, size in zip(chunk_data, img_sizes) + ]) + else: + video_size = img_sizes[0] + video_path = chunk_data[0][1] - db_data.size += len(chunk_data) - progress = extractor.get_progress(chunk_data[-1][2]) - update_progress(progress) + db_data.size += len(chunk_data) + progress = extractor.get_progress(chunk_data[-1][2]) + update_progress(progress) if db_task.mode == 'annotation': models.Image.objects.bulk_create(db_images) @@ -324,4 +384,4 @@ def update_progress(progress): preview.save(db_data.get_preview_path()) slogger.glob.info("Founded frames {} for Data #{}".format(db_data.size, db_data.id)) - _save_task_to_db(db_task) + _save_task_to_db(db_task) \ No newline at end of file diff --git a/cvat/apps/engine/tests/_test_rest_api.py b/cvat/apps/engine/tests/_test_rest_api.py index 73c8114185ee..1e773b2f3f05 100644 --- a/cvat/apps/engine/tests/_test_rest_api.py +++ b/cvat/apps/engine/tests/_test_rest_api.py @@ -72,13 +72,14 @@ def _setUpModule(): import numpy as np from django.conf import settings from django.contrib.auth.models import Group, User +from django.http import HttpResponse from PIL import Image from pycocotools import coco as coco_loader from rest_framework import status from rest_framework.test import APIClient, APITestCase from cvat.apps.engine.models import (AttributeType, Data, Job, Project, - Segment, StatusChoice, Task) + Segment, StatusChoice, Task, StorageMethodChoice) _setUpModule() @@ -1670,7 +1671,8 @@ def _extract_video_chunk(chunk_buffer): stream = container.streams.video[0] return [f.to_image() for f in container.decode(stream)] - def _test_api_v1_tasks_id_data_spec(self, user, spec, data, expected_compressed_type, expected_original_type, image_sizes): + def _test_api_v1_tasks_id_data_spec(self, user, spec, data, expected_compressed_type, expected_original_type, image_sizes, + expected_storage_method=StorageMethodChoice.FILE_SYSTEM): # create task response = self._create_task(user, spec) self.assertEqual(response.status_code, status.HTTP_201_CREATED) @@ -1694,6 +1696,7 @@ def _test_api_v1_tasks_id_data_spec(self, user, spec, data, expected_compressed_ self.assertEqual(expected_compressed_type, task["data_compressed_chunk_type"]) self.assertEqual(expected_original_type, task["data_original_chunk_type"]) self.assertEqual(len(image_sizes), task["size"]) + self.assertEqual(expected_storage_method, Task.objects.get(pk=task_id).data.storage_method) # check preview response = self._get_preview(task_id, user) @@ -1706,7 +1709,10 @@ def _test_api_v1_tasks_id_data_spec(self, user, spec, data, expected_compressed_ response = self._get_compressed_chunk(task_id, user, 0) self.assertEqual(response.status_code, expected_status_code) if expected_status_code == status.HTTP_200_OK: - compressed_chunk = io.BytesIO(b"".join(response.streaming_content)) + if isinstance(response, HttpResponse): + compressed_chunk = io.BytesIO(response.content) + else: + compressed_chunk = io.BytesIO(b"".join(response.streaming_content)) if task["data_compressed_chunk_type"] == self.ChunkType.IMAGESET: images = self._extract_zip_chunk(compressed_chunk) else: @@ -1721,7 +1727,10 @@ def _test_api_v1_tasks_id_data_spec(self, user, spec, data, expected_compressed_ response = self._get_original_chunk(task_id, user, 0) self.assertEqual(response.status_code, expected_status_code) if expected_status_code == status.HTTP_200_OK: - original_chunk = io.BytesIO(b"".join(response.streaming_content)) + if isinstance(response, HttpResponse): + original_chunk = io.BytesIO(response.getvalue()) + else: + original_chunk = io.BytesIO(b"".join(response.streaming_content)) if task["data_original_chunk_type"] == self.ChunkType.IMAGESET: images = self._extract_zip_chunk(original_chunk) else: @@ -1909,6 +1918,74 @@ def _test_api_v1_tasks_id_data(self, user): self._test_api_v1_tasks_id_data_spec(user, task_spec, task_data, self.ChunkType.IMAGESET, self.ChunkType.IMAGESET, image_sizes) + task_spec = { + "name": "use_cache video task #8", + "overlap": 0, + "segment_size": 0, + "labels": [ + {"name": "car"}, + {"name": "person"}, + ] + } + + task_data = { + "server_files[0]": 'test_video_1.mp4', + "image_quality": 70, + "use_cache": True, + } + + image_sizes = self._image_sizes[task_data["server_files[0]"]] + + self._test_api_v1_tasks_id_data_spec(user, task_spec, task_data, self.ChunkType.VIDEO, + self.ChunkType.VIDEO, image_sizes, StorageMethodChoice.CACHE) + + task_spec = { + "name": "use_cache images task #9", + "overlap": 0, + "segment_size": 0, + "labels": [ + {"name": "car"}, + {"name": "person"}, + ] + } + + task_data = { + "server_files[0]": "test_1.jpg", + "server_files[1]": "test_2.jpg", + "server_files[2]": "test_3.jpg", + "image_quality": 70, + "use_cache": True, + } + image_sizes = [ + self._image_sizes[task_data["server_files[0]"]], + self._image_sizes[task_data["server_files[1]"]], + self._image_sizes[task_data["server_files[2]"]], + ] + + self._test_api_v1_tasks_id_data_spec(user, task_spec, task_data, self.ChunkType.IMAGESET, + self.ChunkType.IMAGESET, image_sizes, StorageMethodChoice.CACHE) + + task_spec = { + "name": "my zip archive task #10", + "overlap": 0, + "segment_size": 0, + "labels": [ + {"name": "car"}, + {"name": "person"}, + ] + } + + task_data = { + "server_files[0]": "test_archive_1.zip", + "image_quality": 70, + "use_cache": True + } + + image_sizes = self._image_sizes[task_data["server_files[0]"]] + + self._test_api_v1_tasks_id_data_spec(user, task_spec, task_data, self.ChunkType.IMAGESET, + self.ChunkType.IMAGESET, image_sizes, StorageMethodChoice.CACHE) + def test_api_v1_tasks_id_data_admin(self): self._test_api_v1_tasks_id_data(self.admin) diff --git a/cvat/apps/engine/views.py b/cvat/apps/engine/views.py index 1ee9abc507dd..7e46d836e571 100644 --- a/cvat/apps/engine/views.py +++ b/cvat/apps/engine/views.py @@ -34,7 +34,7 @@ from cvat.apps.authentication import auth from cvat.apps.dataset_manager.serializers import DatasetFormatsSerializer from cvat.apps.engine.frame_provider import FrameProvider -from cvat.apps.engine.models import Job, StatusChoice, Task +from cvat.apps.engine.models import Job, StatusChoice, Task, StorageMethodChoice from cvat.apps.engine.serializers import ( AboutSerializer, AnnotationFileSerializer, BasicUserSerializer, DataMetaSerializer, DataSerializer, ExceptionSerializer, @@ -374,6 +374,11 @@ def data(self, request, pk): db_task.save() data = {k:v for k, v in serializer.data.items()} data['use_zip_chunks'] = serializer.validated_data['use_zip_chunks'] + data['use_cache'] = serializer.validated_data['use_cache'] + if data['use_cache']: + db_task.data.storage_method = StorageMethodChoice.CACHE + db_task.data.save(update_fields=['storage_method']) + # if the value of stop_frame is 0, then inside the function we cannot know # the value specified by the user or it's default value from the database if 'stop_frame' not in serializer.validated_data: @@ -398,16 +403,23 @@ def data(self, request, pk): try: db_task = self.get_object() + db_data = db_task.data frame_provider = FrameProvider(db_task.data) if data_type == 'chunk': data_id = int(data_id) + data_quality = FrameProvider.Quality.COMPRESSED \ if data_quality == 'compressed' else FrameProvider.Quality.ORIGINAL - path = os.path.realpath(frame_provider.get_chunk(data_id, data_quality)) + + #TODO: av.FFmpegError processing + if settings.USE_CACHE and db_data.storage_method == StorageMethodChoice.CACHE: + buff, mime_type = frame_provider.get_chunk(data_id, data_quality) + return HttpResponse(buff.getvalue(), content_type=mime_type) # Follow symbol links if the chunk is a link on a real image otherwise # mimetype detection inside sendfile will work incorrectly. + path = os.path.realpath(frame_provider.get_chunk(data_id, data_quality)) return sendfile(request, path) elif data_type == 'frame': diff --git a/cvat/requirements/base.txt b/cvat/requirements/base.txt index 57ea903152f6..3d4cd0014bab 100644 --- a/cvat/requirements/base.txt +++ b/cvat/requirements/base.txt @@ -18,7 +18,6 @@ rjsmin==1.1.0 requests==2.24.0 rq==1.5.1 rq-scheduler==0.10.0 -scipy==1.4.1 sqlparse==0.3.1 django-sendfile==0.3.11 dj-pagination==2.5.0 @@ -34,19 +33,15 @@ Pygments==2.6.1 drf-yasg==1.17.1 Shapely==1.7.1 pdf2image==1.14.0 -pascal_voc_writer==0.1.4 django-rest-auth[with_social]==0.9.5 cython==0.29.21 -matplotlib==3.0.3 -scikit-image==0.15.0 -tensorflow==2.2.0 -keras==2.4.2 opencv-python==4.4.0.42 h5py==2.10.0 -imgaug==0.4.0 -django-cors-headers==3.4.0 +django-cors-headers==3.5.0 furl==2.1.0 -av==6.2.0 +av==8.0.2 --no-binary=av +tensorflow==2.2.0 # Optional requirement of Datumaro # The package is used by pyunpack as a command line tool to support multiple # archives. Don't use as a python module because it has GPL license. patool==1.12 +diskcache==4.1.0 \ No newline at end of file diff --git a/cvat/requirements/development.txt b/cvat/requirements/development.txt index 7efae6777993..990bf33600d3 100644 --- a/cvat/requirements/development.txt +++ b/cvat/requirements/development.txt @@ -8,6 +8,6 @@ pylint-django==2.3.0 pylint-plugin-utils==0.6 rope==0.17.0 wrapt==1.12.1 -django-extensions==3.0.5 +django-extensions==3.0.6 Werkzeug==1.0.1 snakeviz==2.1.0 diff --git a/cvat/settings/base.py b/cvat/settings/base.py index 6a9a7a6a8828..c803b9f38180 100644 --- a/cvat/settings/base.py +++ b/cvat/settings/base.py @@ -154,7 +154,11 @@ def generate_ssh_keys(): } REST_AUTH_REGISTER_SERIALIZERS = { - 'REGISTER_SERIALIZER': 'cvat.apps.restrictions.serializers.RestrictedRegisterSerializer' + 'REGISTER_SERIALIZER': 'cvat.apps.restrictions.serializers.RestrictedRegisterSerializer', +} + +REST_AUTH_SERIALIZERS = { + 'PASSWORD_RESET_SERIALIZER': 'cvat.apps.authentication.serializers.PasswordResetSerializerEx', } if os.getenv('DJANGO_LOG_VIEWER_HOST'): @@ -324,6 +328,9 @@ def generate_ssh_keys(): MEDIA_DATA_ROOT = os.path.join(DATA_ROOT, 'data') os.makedirs(MEDIA_DATA_ROOT, exist_ok=True) +CACHE_ROOT = os.path.join(DATA_ROOT, 'cache') +os.makedirs(CACHE_ROOT, exist_ok=True) + TASKS_ROOT = os.path.join(DATA_ROOT, 'tasks') os.makedirs(TASKS_ROOT, exist_ok=True) @@ -422,3 +429,17 @@ def generate_ssh_keys(): 'engine.role.admin', ), } + +CACHES = { + 'default' : { + 'BACKEND' : 'diskcache.DjangoCache', + 'LOCATION' : CACHE_ROOT, + 'TIMEOUT' : None, + 'OPTIONS' : { + #'statistics' :True, + 'size_limit' : 2 ** 40, # 1 тб + } + } +} + +USE_CACHE = True diff --git a/cvat/settings/testing.py b/cvat/settings/testing.py index 35bf5eaf2244..9825349fd8b7 100644 --- a/cvat/settings/testing.py +++ b/cvat/settings/testing.py @@ -22,6 +22,8 @@ MODELS_ROOT = os.path.join(DATA_ROOT, 'models') os.makedirs(MODELS_ROOT, exist_ok=True) +CACHE_ROOT = os.path.join(DATA_ROOT, 'cache') +os.makedirs(CACHE_ROOT, exist_ok=True) # To avoid ERROR django.security.SuspiciousFileOperation: # The joined path (...) is located outside of the base path component diff --git a/datumaro/requirements.txt b/datumaro/requirements.txt index ce5837837111..b5142853b21f 100644 --- a/datumaro/requirements.txt +++ b/datumaro/requirements.txt @@ -3,7 +3,7 @@ Cython>=0.27.3 # include before pycocotools defusedxml>=0.6.0 GitPython>=3.0.8 lxml>=4.4.1 -matplotlib<3.1 # 3.1+ requires python3.6, but we have 3.5 in cvat +matplotlib>=3.3.1 opencv-python-headless>=4.1.0.25 Pillow>=6.1.0 pycocotools>=2.0.0 diff --git a/tests/cypress.json b/tests/cypress.json index 59316936ea24..73741d19e242 100644 --- a/tests/cypress.json +++ b/tests/cypress.json @@ -10,6 +10,7 @@ }, "testFiles": [ "auth_page.js", - "issue_*.js" + "issue_*.js", + "remove_users_tasks.js" ] } diff --git a/tests/cypress/integration/issue_1444_filter_property_shape.js b/tests/cypress/integration/issue_1444_filter_property_shape.js new file mode 100644 index 000000000000..6859869d00ff --- /dev/null +++ b/tests/cypress/integration/issue_1444_filter_property_shape.js @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2020 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +/// + +context('Filter property "shape" work correctly', () => { + + const issueId = '1444' + const labelName = `Issue ${issueId}` + const taskName = `New annotation task for ${labelName}` + const attrName = `Attr for ${labelName}` + const textDefaultValue = 'Some default value for type Text' + const image = `image_${issueId}.png` + const width = 800 + const height = 800 + const posX = 10 + const posY = 10 + const color = 'white' + + before(() => { + cy.visit('auth/login') + cy.login() + cy.imageGenerator('cypress/fixtures', image, width, height, color, posX, posY, labelName) + cy.createAnnotationTask(taskName, labelName, attrName, textDefaultValue, image) + cy.openTaskJob(taskName) + }) + + describe(`Testing issue "${issueId}"`, () => { + it('Create a shape', () => { + cy.createShape(309, 431, 616, 671) + cy.get('#cvat-objects-sidebar-state-item-1') + .should('contain', '1').and('contain', 'RECTANGLE SHAPE') + }) + it('Create a polygon', () => { + cy.createPolygon('Shape', [ + {x: 300, y: 100}, + {x: 400, y: 400}, + {x: 400, y: 250}, + ]) + cy.get('#cvat-objects-sidebar-state-item-2') + .should('contain', '2').and('contain', 'POLYGON SHAPE') + }) + it('Input filter "shape == "polygon""', () => { + cy.get('.cvat-annotations-filters-input') + .type('shape == "polygon"{Enter}') + }) + it('Only polygon is visible', () => { + cy.get('#cvat_canvas_shape_2') + .should('exist') + cy.get('#cvat_canvas_shape_1') + .should('not.exist') + }) + }) +}) diff --git a/tests/cypress/integration/issue_1498_message_ui_raw_labels_wrong.js b/tests/cypress/integration/issue_1498_message_ui_raw_labels_wrong.js new file mode 100644 index 000000000000..ca6a50bb27f2 --- /dev/null +++ b/tests/cypress/integration/issue_1498_message_ui_raw_labels_wrong.js @@ -0,0 +1,169 @@ +/* + * Copyright (C) 2020 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +/// + +context('Message in UI when raw labels are wrong.', () => { + + const issueId = '1498' + const labelName = `Issue ${issueId}` + const attrName = `Attr for ${labelName}` + const textDefaultValue = 'Some default value for type Text' + let taskRaw = [ + { + name: labelName, + id: 1, + color: '#c4a71f', + attributes: [ + { + id: 1, + name: attrName, + input_type: 'text', + mutable: false, + values: [ + textDefaultValue + ] + } + ] + } + ] + + before(() => { + cy.visit('auth/login') + cy.login() + cy.get('#cvat-create-task-button').click() + cy.url().should('include', '/tasks/create') + cy.get('[role="tab"]').contains('Raw').click() + }) + + beforeEach('Clear "Raw" field', () =>{ + cy.get('#labels').clear() + cy.task('log', '\n') + }) + + describe(`Testing issue "${issueId}"`, () => { + it('"Raw" field is empty.', () =>{ + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + }) + it('Label "name" as a number.', () => { + let taskRawNameNumber = JSON.parse(JSON.stringify(taskRaw)) + taskRawNameNumber[0].name = 1 + let jsonNameNumber = JSON.stringify(taskRawNameNumber) + cy.get('#labels').type(jsonNameNumber, { parseSpecialCharSequences: false }) + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + }) + it('Label "id" as a string.', () => { + let taskRawLabelString = JSON.parse(JSON.stringify(taskRaw)) + taskRawLabelString[0].id = "1" + let jsonLabelString = JSON.stringify(taskRawLabelString) + cy.get('#labels').type(jsonLabelString, { parseSpecialCharSequences: false }) + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + }) + it('Label "attributes" as a number.', () => { + let taskRawAttrNumber = JSON.parse(JSON.stringify(taskRaw)) + taskRawAttrNumber[0].attributes = 1 + let jsonAttrNumber = JSON.stringify(taskRawAttrNumber) + cy.get('#labels').type(jsonAttrNumber, { parseSpecialCharSequences: false }) + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + }) + it('Label "color" as a number.', () => { + let taskRawColorNumber = JSON.parse(JSON.stringify(taskRaw)) + taskRawColorNumber[0].color = 1 + let jsonColorNumber = JSON.stringify(taskRawColorNumber) + cy.get('#labels').type(jsonColorNumber, { parseSpecialCharSequences: false }) + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + }) + it('Label "attributes id" as a string.', () => { + let taskRawAttrIdString = JSON.parse(JSON.stringify(taskRaw)) + taskRawAttrIdString[0].attributes[0].id = "1" + let jsonAttrIdString = JSON.stringify(taskRawAttrIdString) + cy.get('#labels').type(jsonAttrIdString, { parseSpecialCharSequences: false }) + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + }) + it('Label "attributes input_type" is incorrect.', () => { + const inputTypes = ['select radio', 'textt', 'nnumber'] + let taskRawAttrTypeNumber = JSON.parse(JSON.stringify(taskRaw)) + for (let type of inputTypes) { + taskRawAttrTypeNumber[0].attributes[0].input_type = type + let jsonAttrTypeNumber = JSON.stringify(taskRawAttrTypeNumber) + cy.get('#labels').type(jsonAttrTypeNumber, { parseSpecialCharSequences: false }) + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + cy.get('#labels').clear() + } + }) + it('Label "attributes mutable" as a number.', () => { + let taskRawAttrMutableNumber = JSON.parse(JSON.stringify(taskRaw)) + taskRawAttrMutableNumber[0].attributes[0].mutable = 1 + let jsonAttrMutableNumber = JSON.stringify(taskRawAttrMutableNumber) + cy.get('#labels').type(jsonAttrMutableNumber, { parseSpecialCharSequences: false }) + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + }) + it('Label "attributes values" as a number.', () => { + let taskRawAttrValuesNumber = JSON.parse(JSON.stringify(taskRaw)) + taskRawAttrValuesNumber[0].attributes[0].values = 1 + let jsonAttrValueNumber = JSON.stringify(taskRawAttrValuesNumber) + cy.get('#labels').type(jsonAttrValueNumber, { parseSpecialCharSequences: false }) + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + }) + it('Label "attributes values" as a array with number.', () => { + let taskRawAttrValuesNumberArr = JSON.parse(JSON.stringify(taskRaw)) + taskRawAttrValuesNumberArr[0].attributes[0].values = [1] + let jsonAttrValuesNumberArr = JSON.stringify(taskRawAttrValuesNumberArr) + cy.get('#labels').type(jsonAttrValuesNumberArr, { parseSpecialCharSequences: false }) + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + }) + it('Label "attributes name" as a number.', () => { + let taskRawAttrNameNumber = JSON.parse(JSON.stringify(taskRaw)) + taskRawAttrNameNumber[0].attributes[0].name = 1 + let jsonAttrNameNumber = JSON.stringify(taskRawAttrNameNumber) + cy.get('#labels').type(jsonAttrNameNumber, { parseSpecialCharSequences: false }) + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + }) + it('Label "attributes values" as a empty array.', () => { + let taskRawAttrValuesEmptyArr = JSON.parse(JSON.stringify(taskRaw)) + taskRawAttrValuesEmptyArr[0].attributes[0].values = [] + let jsonAttrValuesEmptyArr = JSON.stringify(taskRawAttrValuesEmptyArr) + cy.get('#labels').type(jsonAttrValuesEmptyArr, { parseSpecialCharSequences: false }) + cy.get('.ant-form-explain') + .should('exist').invoke('text').then(($explainText) => { + cy.task('log', `- "${$explainText}"`) + }) + }) + }) +}) diff --git a/tests/cypress/integration/issue_1540_add_remove_tag.js b/tests/cypress/integration/issue_1540_add_remove_tag.js new file mode 100644 index 000000000000..683985b6616c --- /dev/null +++ b/tests/cypress/integration/issue_1540_add_remove_tag.js @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2020 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +/// + +context('Check if the UI not to crash after remove a tag', () => { + + const issueId = '1540' + const labelName = `Issue ${issueId}` + const taskName = `New annotation task for ${labelName}` + const attrName = `Attr for ${labelName}` + const textDefaultValue = 'Some default value for type Text' + const image = `image_${issueId}.png` + const width = 800 + const height = 800 + const posX = 10 + const posY = 10 + const color = 'gray' + + before(() => { + cy.visit('auth/login') + cy.login() + cy.imageGenerator('cypress/fixtures', image, width, height, color, posX, posY, labelName) + cy.createAnnotationTask(taskName, labelName, attrName, textDefaultValue, image) + cy.openTaskJob(taskName) + }) + + describe(`Testing issue "${issueId}"`, () => { + it('Add a tag', () => { + cy.changeAnnotationMode('Tag annotation') + cy.get('.cvat-tag-annotation-sidebar-buttons').within(() => { + cy.get('button') + .contains('Add tag') + .click({force: true}) + }) + cy.changeAnnotationMode('Standard') + }) + it('Remove the tag', () => { + cy.get('#cvat-objects-sidebar-state-item-1') + .should('contain', '1').and('contain', 'TAG') + .trigger('mouseover') + .trigger('keydown', {key: 'Delete'}) + }) + it('Page with the error is missing', () => { + cy.contains('Oops, something went wrong') + .should('not.exist') + }) + }) +}) diff --git a/tests/cypress/integration/remove_users_tasks.js b/tests/cypress/integration/remove_users_tasks.js new file mode 100644 index 000000000000..711b4e095dce --- /dev/null +++ b/tests/cypress/integration/remove_users_tasks.js @@ -0,0 +1,69 @@ +/* + * Copyright (C) 2020 Intel Corporation + * + * SPDX-License-Identifier: MIT + */ + +/// + +let authKey = '' + +describe('Delete users and tasks created during the test run.', () => { + it('Get token', () => { + cy.request({ + method: 'POST', + url: '/api/v1/auth/login', + body: { + username: Cypress.env('user'), + password: Cypress.env('password') + } + }) + .then(async (responce) => { + authKey = await responce['body']['key'] + }) + }) + it('Get a list of users and delete all except id:1', () => { + cy.request({ + url: '/api/v1/users', + headers: { + Authorization: `Token ${authKey}` + } + }) + .then(async (responce) => { + const responceResult = await responce['body']['results'] + for (let user of responceResult) { + let userId = user['id'] + if (userId !== 1) { + cy.request({ + method: 'DELETE', + url: `/api/v1/users/${userId}`, + headers: { + Authorization: `Token ${authKey}` + } + }) + } + } + }) + }) + it('Get a list of tasks and delete them all', ()=> { + cy.request({ + url: '/api/v1/tasks?page_size=1000', + headers: { + Authorization: `Token ${authKey}` + } + }) + .then(async (responce) => { + const responceResult = await responce['body']['results'] + for (let tasks of responceResult) { + let taskId = tasks['id'] + cy.request({ + method: 'DELETE', + url: `/api/v1/tasks/${taskId}`, + headers: { + Authorization: `Token ${authKey}` + } + }) + } + }) + }) +}) diff --git a/tests/cypress/plugins/index.js b/tests/cypress/plugins/index.js index 8e09a2cf5bc1..bcb84bcaf091 100644 --- a/tests/cypress/plugins/index.js +++ b/tests/cypress/plugins/index.js @@ -12,4 +12,10 @@ const {createZipArchive} = require('../plugins/createZipArchive/addPlugin') module.exports = (on) => { on('task', {imageGenerator}) on('task', {createZipArchive}) + on('task', { + log(message) { + console.log(message) + return null + } + }) } diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js index ec6538c93f8a..b0274a4a2484 100644 --- a/tests/cypress/support/commands.js +++ b/tests/cypress/support/commands.js @@ -32,12 +32,12 @@ Cypress.Commands.add('createAnnotationTask', (taksName='New annotation task', image='image.png', multiJobs=false, segmentSize=1) => { - cy.contains('button', 'Create new task').click() + cy.get('#cvat-create-task-button').click() cy.url().should('include', '/tasks/create') cy.get('[id="name"]').type(taksName) - cy.contains('button', 'Add label').click() + cy.get('.cvat-constructor-viewer-new-item').click() cy.get('[placeholder="Label name"]').type(labelName) - cy.contains('button', 'Add an attribute').click() + cy.get('.cvat-new-attribute-button').click() cy.get('[placeholder="Name"]').type(attrName) cy.get('div[title="Select"]').click() cy.get('li').contains('Text').click() @@ -136,10 +136,13 @@ Cypress.Commands.add('createPolygon', ( mode, reDraw=false) => { if (!reDraw) { cy.get('.cvat-draw-polygon-control').click() - cy.get('.cvat-draw-shape-popover-content') - .find('button') - .contains(mode) - .click({force: true}) + cy.contains('Draw new polygon') + .parents('.cvat-draw-shape-popover-content') + .within(() => { + cy.get('button') + .contains(mode) + .click({force: true}) + }) } pointsMap.forEach(element => { cy.get('.cvat-canvas-container') @@ -167,3 +170,13 @@ Cypress.Commands.add('closeSettings', () => { cy.contains('button', 'Close').click() }) }) + +Cypress.Commands.add('changeAnnotationMode', (mode) => { + cy.get('.cvat-workspace-selector') + .click() + cy.get('.ant-select-dropdown-menu-item') + .contains(mode) + .click() + cy.get('.cvat-workspace-selector') + .should('contain.text', mode) +}) diff --git a/tests/cypress/support/index.js b/tests/cypress/support/index.js index bf9295aaba4a..b23790a41bb8 100644 --- a/tests/cypress/support/index.js +++ b/tests/cypress/support/index.js @@ -5,3 +5,16 @@ */ import './commands' + +before(() => { + if (Cypress.browser.name === 'firefox') { + cy.visit('/') + cy.get('.ant-modal-body').within(() => { + cy.get('.ant-modal-confirm-title') + .should('contain', 'Unsupported platform detected') + cy.get('.ant-modal-confirm-btns') + .contains('OK') + .click() + }) + } +})