diff --git a/.github/workflows/schedule.yml b/.github/workflows/schedule.yml
index f7244d915c42..7fd9e6f63045 100644
--- a/.github/workflows/schedule.yml
+++ b/.github/workflows/schedule.yml
@@ -313,6 +313,13 @@ jobs:
name: cypress_screenshots
path: ${{ github.workspace }}/tests/cypress/screenshots
+ - name: Uploading cypress videos as an artifact
+ if: failure()
+ uses: actions/upload-artifact@v3.1.1
+ with:
+ name: cypress_videos_${{ matrix.specs }}
+ path: ${{ github.workspace }}/tests/cypress/videos
+
- name: Uploading "cvat" container logs as an artifact
if: failure()
uses: actions/upload-artifact@v3.1.1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec09cc3ba2d0..b34bca737e6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,61 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
+
+## \[2.14.0\] - 2024-05-21
+
+### Added
+
+- Added security headers enforcing strict `Referrer-Policy` for cross origins and disabling MIME type sniffing via `X-Content-Type-Options`.
+ ()
+
+- \[Helm\] Ability to specify ServiceAccount for backend pods
+ ()
+
+### Changed
+
+- Working time rounding to a minimal value of 1 hour is not applied to the annotation speed metric any more
+ ()
+
+- Total annotation speed metric renamed to Average annotation speed
+ ()
+
+- Ground truth jobs are not considered when computing analytics report for a task/project
+ ()
+
+### Fixed
+
+- Fixed calculation of annotation speed metrics for analytics reports
+ ()
+
+- \[Helm\] Prevented spurious 200 OK responses from API endpoints
+ before the backend is ready
+ ()
+
+- Analytic reports incorrect count of objects for a skeleton track/shape
+ ()
+
+- Analytic reports incorrect number of objects for a track (always less by 1)
+ ()
+
+- REST API allowed to create several attributes with the same name within one label
+ ()
+
+- Job's/task's status are not updated when job's state updated to completed and stage is already acceptance
+ ()
+
+- Exception: Cannot read properties of undefined (reading 'onBlockUpdated')
+ ()
+
+- One more found way to create an empty mask
+ ()
+
+- Slice function may not work in Google Chrome < 110
+ ()
+
+- Selecting a skeleton by cursor does not work correctly when there are some hidden points
+ ()
+
## \[2.13.0\] - 2024-05-09
diff --git a/cvat-canvas/src/typescript/masksHandler.ts b/cvat-canvas/src/typescript/masksHandler.ts
index e782afc35b8e..cdaa4d86d2fa 100644
--- a/cvat-canvas/src/typescript/masksHandler.ts
+++ b/cvat-canvas/src/typescript/masksHandler.ts
@@ -201,8 +201,8 @@ export class MasksHandlerImpl implements MasksHandler {
.reduce((acc: TwoCornerBox, rect: BoundingRect) => {
acc.top = Math.floor(Math.max(0, Math.min(rect.top, acc.top)));
acc.left = Math.floor(Math.max(0, Math.min(rect.left, acc.left)));
- acc.bottom = Math.floor(Math.min(height, Math.max(rect.top + rect.height, acc.bottom)));
- acc.right = Math.floor(Math.min(width, Math.max(rect.left + rect.width, acc.right)));
+ acc.bottom = Math.floor(Math.min(height - 1, Math.max(rect.top + rect.height, acc.bottom)));
+ acc.right = Math.floor(Math.min(width - 1, Math.max(rect.left + rect.width, acc.right)));
return acc;
}, {
left: Number.MAX_SAFE_INTEGER,
@@ -572,7 +572,14 @@ export class MasksHandlerImpl implements MasksHandler {
image.globalCompositeOperation = 'xor';
image.opacity = 0.5;
this.canvas.add(image);
- this.drawnObjects.push(image);
+ /*
+ when we paste a mask, we do not need additional logic implemented
+ in MasksHandlerImpl::createDrawnObjectsArray.push using JS Proxy
+ because we will not work with any drawing tools here, and it will cause the issue
+ because this.tools may be undefined here
+ when it is used inside the push custom implementation
+ */
+ this.drawnObjects = [image];
this.canvas.renderAll();
} finally {
resolve();
diff --git a/cvat-canvas/src/typescript/shared.ts b/cvat-canvas/src/typescript/shared.ts
index 2273392da522..b92b047f8ea7 100644
--- a/cvat-canvas/src/typescript/shared.ts
+++ b/cvat-canvas/src/typescript/shared.ts
@@ -1,4 +1,5 @@
// Copyright (C) 2019-2022 Intel Corporation
+// Copyright (C) 2024 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@@ -535,5 +536,15 @@ export function segmentsFromPoints(points: number[], circuit = false): Segment[]
}, []);
}
+export function toReversed(array: Array): Array {
+ // actually toReversed already exists in ESMA specification
+ // but not all CVAT customers uses a browser fresh enough to use it
+ // instead of using a library with polyfills I will prefer just to rewrite it with reduceRight
+ return array.reduceRight>((acc, val: T) => {
+ acc.push(val);
+ return acc;
+ }, []);
+}
+
export type Segment = [[number, number], [number, number]];
export type PropType = T[Prop];
diff --git a/cvat-canvas/src/typescript/sliceHandler.ts b/cvat-canvas/src/typescript/sliceHandler.ts
index e5f65fa1e0c9..d148027fe366 100644
--- a/cvat-canvas/src/typescript/sliceHandler.ts
+++ b/cvat-canvas/src/typescript/sliceHandler.ts
@@ -1,4 +1,4 @@
-// Copyright (C) 2023 CVAT.ai Corporation
+// Copyright (C) 2023-2024 CVAT.ai Corporation
//
// SPDX-License-Identifier: MIT
@@ -6,6 +6,7 @@ import * as SVG from 'svg.js';
import {
stringifyPoints, translateToCanvas, translateFromCanvas, translateToSVG,
findIntersection, zipChannels, Segment, findClosestPointOnSegment, segmentsFromPoints,
+ toReversed,
} from './shared';
import {
Geometry, SliceData, Configuration, CanvasHint,
@@ -294,8 +295,7 @@ export class SliceHandlerImpl implements SliceHandler {
const d2 = Math.sqrt((p2[0] - p[0]) ** 2 + (p2[1] - p[1]) ** 2);
if (d2 > d1) {
- // @ts-ignore error TS2551 (need to update typescript up to 5.2)
- contour2.push(...otherPoints.toReversed().flat());
+ contour2.push(...toReversed<[number, number]>(otherPoints).flat());
} else {
contour2.push(...otherPoints.flat());
}
@@ -312,8 +312,7 @@ export class SliceHandlerImpl implements SliceHandler {
...firstSegmentPoint, // first intersection
// intermediate points (reversed if intersections order was swopped)
...(firstSegmentIdx === firstIntersectedSegmentIdx ?
- // @ts-ignore error TS2551 (need to update typescript up to 5.2)
- intermediatePoints : intermediatePoints.toReversed()
+ intermediatePoints : toReversed<[number, number]>(intermediatePoints)
).flat(),
// second intersection
...secondSegmentPoint,
@@ -326,8 +325,7 @@ export class SliceHandlerImpl implements SliceHandler {
...firstSegmentPoint, // first intersection
// intermediate points (reversed if intersections order was swopped)
...(firstSegmentIdx === firstIntersectedSegmentIdx ?
- // @ts-ignore error TS2551 (need to update typescript up to 5.2)
- intermediatePoints : intermediatePoints.toReversed()
+ intermediatePoints : toReversed<[number, number]>(intermediatePoints)
).flat(),
...secondSegmentPoint,
// all the previous contours points N, N-1, .. until (including) the first intersected segment
diff --git a/cvat-cli/requirements/base.txt b/cvat-cli/requirements/base.txt
index 31d1de00d5ab..a969bfff7bb0 100644
--- a/cvat-cli/requirements/base.txt
+++ b/cvat-cli/requirements/base.txt
@@ -1,3 +1,3 @@
-cvat-sdk~=2.13.0
+cvat-sdk~=2.14.0
Pillow>=10.3.0
setuptools>=65.5.1 # not directly required, pinned by Snyk to avoid a vulnerability
diff --git a/cvat-cli/src/cvat_cli/version.py b/cvat-cli/src/cvat_cli/version.py
index e3b5cba094f6..aa94721acb28 100644
--- a/cvat-cli/src/cvat_cli/version.py
+++ b/cvat-cli/src/cvat_cli/version.py
@@ -1 +1 @@
-VERSION = "2.13.0"
+VERSION = "2.14.0"
diff --git a/cvat-core/src/annotations-collection.ts b/cvat-core/src/annotations-collection.ts
index ed380838fa10..257e18fbad24 100644
--- a/cvat-core/src/annotations-collection.ts
+++ b/cvat-core/src/annotations-collection.ts
@@ -116,7 +116,7 @@ export default class Collection {
};
}
- import(data: Omit): ImportedCollection {
+ public import(data: Omit): ImportedCollection {
const result = {
tags: [],
shapes: [],
@@ -159,7 +159,7 @@ export default class Collection {
return result;
}
- export(): Omit {
+ public export(): Omit {
const data = {
tracks: this.tracks.filter((track) => !track.removed).map((track) => track.toJSON() as SerializedTrack),
shapes: Object.values(this.shapes)
@@ -215,7 +215,7 @@ export default class Collection {
return objectStates;
}
- _mergeInternal(objectsForMerge: (Track | Shape)[], shapeType: ShapeType, label: Label): SerializedTrack {
+ private _mergeInternal(objectsForMerge: (Track | Shape)[], shapeType: ShapeType, label: Label): SerializedTrack {
const keyframes: Record = {}; // frame: position
const elements = {}; // element_sublabel_id: [element], each sublabel will be merged recursively
@@ -398,7 +398,7 @@ export default class Collection {
return track;
}
- merge(objectStates: ObjectState[]): void {
+ public merge(objectStates: ObjectState[]): void {
checkObjectType('shapes to merge', objectStates, null, Array);
if (!objectStates.length) return;
const objectsForMerge = objectStates.map((state) => {
@@ -455,7 +455,7 @@ export default class Collection {
);
}
- _splitInternal(objectState: ObjectState, object: Track, frame: number): SerializedTrack[] {
+ private _splitInternal(objectState: ObjectState, object: Track, frame: number): SerializedTrack[] {
const labelAttributes = labelAttributesAsDict(object.label);
// first clear all server ids which may exist in the object being splitted
const copy = trackFactory(object.toJSON(), -1, this.injection);
@@ -523,7 +523,7 @@ export default class Collection {
return [prev, next];
}
- split(objectState: ObjectState, frame: number): void {
+ public split(objectState: ObjectState, frame: number): void {
checkObjectType('object state', objectState, null, ObjectState);
checkObjectType('frame', frame, 'integer', null);
@@ -564,7 +564,7 @@ export default class Collection {
);
}
- group(objectStates: ObjectState[], reset: boolean): number {
+ public group(objectStates: ObjectState[], reset: boolean): number {
checkObjectType('shapes to group', objectStates, null, Array);
const objectsForGroup = objectStates.map((state) => {
@@ -605,7 +605,7 @@ export default class Collection {
return groupIdx;
}
- join(objectStates: ObjectState[], points: number[]): void {
+ public join(objectStates: ObjectState[], points: number[]): void {
checkObjectType('shapes to join', objectStates, null, Array);
checkObjectType('joined rle mask', points, null, Array);
@@ -690,7 +690,7 @@ export default class Collection {
}
}
- slice(state: ObjectState, results: number[][]): void {
+ public slice(state: ObjectState, results: number[][]): void {
if (results.length !== 2) {
throw new Error('Not supported slicing count');
}
@@ -774,7 +774,7 @@ export default class Collection {
);
}
- clear(startframe: number, endframe: number, delTrackKeyframesOnly: boolean): void {
+ public clear(startframe: number, endframe: number, delTrackKeyframesOnly: boolean): void {
if (startframe !== undefined && endframe !== undefined) {
// If only a range of annotations need to be cleared
for (let frame = startframe; frame <= endframe; frame++) {
@@ -818,7 +818,7 @@ export default class Collection {
}
}
- statistics(): Statistics {
+ public statistics(): Statistics {
const labels = {};
const shapes = ['rectangle', 'polygon', 'polyline', 'points', 'ellipse', 'cuboid', 'skeleton'];
const body = {
@@ -958,7 +958,7 @@ export default class Collection {
return new Statistics(labels, total);
}
- put(objectStates: ObjectState[]): number[] {
+ public put(objectStates: ObjectState[]): number[] {
checkObjectType('shapes for put', objectStates, null, Array);
const constructed = {
shapes: [],
@@ -1149,7 +1149,7 @@ export default class Collection {
return importedArray.map((value) => value.clientID);
}
- select(objectStates: ObjectState[], x: number, y: number): {
+ public select(objectStates: ObjectState[], x: number, y: number): {
state: ObjectState,
distance: number | null,
} {
@@ -1195,7 +1195,13 @@ export default class Collection {
throw new ArgumentError(`Unknown shape type "${state.shapeType}"`);
}
- const distance = distanceMetric(state.points, x, y, state.rotation);
+ let points = [];
+ if (state.shapeType === ShapeType.SKELETON) {
+ points = state.elements.filter((el) => !el.outside && !el.hidden).map((el) => el.points).flat();
+ } else {
+ points = state.points;
+ }
+ const distance = distanceMetric(points, x, y, state.rotation);
if (distance !== null && (minimumDistance === null || distance < minimumDistance)) {
minimumDistance = distance;
minimumState = state;
@@ -1208,7 +1214,7 @@ export default class Collection {
};
}
- _searchEmpty(
+ private _searchEmpty(
frameFrom: number,
frameTo: number,
searchParameters: {
@@ -1254,7 +1260,7 @@ export default class Collection {
return null;
}
- search(
+ public search(
frameFrom: number,
frameTo: number,
searchParameters: {
diff --git a/cvat-core/src/annotations-objects.ts b/cvat-core/src/annotations-objects.ts
index 03331d101ede..70c28c81def3 100644
--- a/cvat-core/src/annotations-objects.ts
+++ b/cvat-core/src/annotations-objects.ts
@@ -1936,8 +1936,7 @@ export class SkeletonShape extends Shape {
return null;
}
- // The shortest distance from point to an edge
- return Math.min.apply(null, [x - xtl, y - ytl, xbr - x, ybr - y]);
+ return Math.min.apply(null, distances);
}
// Method is used to export data to the server
diff --git a/cvat-sdk/gen/generate.sh b/cvat-sdk/gen/generate.sh
index ec7f6217145f..9bde2ac9d235 100755
--- a/cvat-sdk/gen/generate.sh
+++ b/cvat-sdk/gen/generate.sh
@@ -8,7 +8,7 @@ set -e
GENERATOR_VERSION="v6.0.1"
-VERSION="2.13.0"
+VERSION="2.14.0"
LIB_NAME="cvat_sdk"
LAYER1_LIB_NAME="${LIB_NAME}/api_client"
DST_DIR="$(cd "$(dirname -- "$0")/.." && pwd)"
diff --git a/cvat-ui/react_nginx.conf b/cvat-ui/react_nginx.conf
index c3d51866beab..5f1f4b48997a 100644
--- a/cvat-ui/react_nginx.conf
+++ b/cvat-ui/react_nginx.conf
@@ -26,6 +26,8 @@ server {
add_header Cross-Origin-Embedder-Policy "credentialless";
add_header Expires 0;
add_header X-Frame-Options "deny";
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header X-Content-Type-Options "nosniff" always;
}
location /assets {
diff --git a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx
index 9dc70220ac10..6d6c332b39ab 100644
--- a/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx
+++ b/cvat-ui/src/components/annotation-page/canvas/views/canvas2d/canvas-wrapper.tsx
@@ -808,7 +808,7 @@ class CanvasWrapperComponent extends React.PureComponent {
const result = await jobInstance.annotations.select(event.detail.states, event.detail.x, event.detail.y);
if (result && result.state) {
- if (['polyline', 'points'].includes(result.state.shapeType)) {
+ if ([ShapeType.POLYLINE, ShapeType.POINTS].includes(result.state.shapeType)) {
if (result.distance > MAX_DISTANCE_TO_OPEN_SHAPE) {
return;
}
diff --git a/cvat-ui/src/reducers/tasks-reducer.ts b/cvat-ui/src/reducers/tasks-reducer.ts
index 8efcde107509..ce2e88258c0c 100644
--- a/cvat-ui/src/reducers/tasks-reducer.ts
+++ b/cvat-ui/src/reducers/tasks-reducer.ts
@@ -8,6 +8,7 @@ import { BoundariesActionTypes } from 'actions/boundaries-actions';
import { TasksActionTypes } from 'actions/tasks-actions';
import { AuthActionTypes } from 'actions/auth-actions';
+import { ProjectsActionTypes } from 'actions/projects-actions';
import { TasksState } from '.';
const defaultState: TasksState = {
@@ -77,6 +78,13 @@ export default (state: TasksState = defaultState, action: AnyAction): TasksState
initialized: true,
fetching: false,
};
+ case ProjectsActionTypes.DELETE_PROJECT_SUCCESS: {
+ const { projectId } = action.payload;
+ return {
+ ...state,
+ current: state.current.filter((_task) => _task.projectId !== projectId),
+ };
+ }
case TasksActionTypes.DELETE_TASK: {
const { taskID } = action.payload;
const { deletes } = state.activities;
diff --git a/cvat/__init__.py b/cvat/__init__.py
index 7e06feab2044..22fb7a85b4b2 100644
--- a/cvat/__init__.py
+++ b/cvat/__init__.py
@@ -4,6 +4,6 @@
from cvat.utils.version import get_version
-VERSION = (2, 13, 0, 'final', 0)
+VERSION = (2, 14, 0, 'final', 0)
__version__ = get_version(VERSION)
diff --git a/cvat/apps/analytics_report/migrations/0002_fix_annotation_speed.py b/cvat/apps/analytics_report/migrations/0002_fix_annotation_speed.py
new file mode 100644
index 000000000000..7137dee1ca2c
--- /dev/null
+++ b/cvat/apps/analytics_report/migrations/0002_fix_annotation_speed.py
@@ -0,0 +1,49 @@
+# Generated by Django 4.2.11 on 2024-05-10 06:00
+
+from django.db import migrations
+
+
+def upgrade_report(report):
+ statistics = [stat for stat in report.statistics if stat["name"] == "annotation_speed"]
+ modified = False
+
+ for statistic in statistics:
+ if len(statistic["data_series"]["object_count"]) > 2:
+ records_1 = list(reversed(statistic["data_series"]["object_count"]))
+ records_2 = records_1[1:]
+ for next_item, prev_item in zip(records_1, records_2):
+ next_item["value"] += prev_item["value"]
+ previous_count = 0
+ for item in statistic["data_series"]["object_count"]:
+ item["value"] -= previous_count
+ previous_count += item["value"]
+ modified = True
+
+ return report if modified else None
+
+
+def forwards_func(apps, schema_editor):
+ AnalyticsReport = apps.get_model("analytics_report", "AnalyticsReport")
+
+ # first upgrade all reports, related to jobs
+ reports = AnalyticsReport.objects.exclude(job_id=None).all()
+ objects_to_update = []
+ for report in reports:
+ try:
+ objects_to_update.append(upgrade_report(report))
+ except Exception: # nosec B110
+ # I do not expect exception to happen here
+ # but if it happened, let's just ignore the report
+ pass
+
+ objects_to_update = list(filter(lambda x: x is not None, objects_to_update))
+ AnalyticsReport.objects.bulk_update(objects_to_update, fields=["statistics"], batch_size=500)
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("analytics_report", "0001_initial"),
+ ]
+
+ operations = [migrations.RunPython(forwards_func)]
diff --git a/cvat/apps/analytics_report/report/create.py b/cvat/apps/analytics_report/report/create.py
index 10cca9bb0235..e028bb590ed1 100644
--- a/cvat/apps/analytics_report/report/create.py
+++ b/cvat/apps/analytics_report/report/create.py
@@ -14,17 +14,17 @@
from cvat.apps.analytics_report.models import AnalyticsReport
from cvat.apps.analytics_report.report.derived_metrics import (
DerivedMetricBase,
- JobTotalAnnotationSpeed,
+ JobAverageAnnotationSpeed,
JobTotalObjectCount,
ProjectAnnotationSpeed,
ProjectAnnotationTime,
+ ProjectAverageAnnotationSpeed,
ProjectObjects,
- ProjectTotalAnnotationSpeed,
ProjectTotalObjectCount,
TaskAnnotationSpeed,
TaskAnnotationTime,
+ TaskAverageAnnotationSpeed,
TaskObjects,
- TaskTotalAnnotationSpeed,
TaskTotalObjectCount,
)
from cvat.apps.analytics_report.report.primary_metrics import (
@@ -36,7 +36,7 @@
JobObjectsExtractor,
PrimaryMetricBase,
)
-from cvat.apps.engine.models import Job, Project, Task
+from cvat.apps.engine.models import Job, JobType, Project, Task
def get_empty_report():
@@ -45,7 +45,7 @@ def get_empty_report():
JobAnnotationSpeed(None),
JobAnnotationTime(None),
JobTotalObjectCount(None),
- JobTotalAnnotationSpeed(None),
+ JobAverageAnnotationSpeed(None),
]
statistics = [AnalyticsReportUpdateManager._get_empty_statistics_entry(dm) for dm in metrics]
@@ -113,11 +113,11 @@ def _get_analytics_report(db_obj: Union[Job, Task, Project]) -> AnalyticsReport:
db_report = AnalyticsReport(statistics=[])
if isinstance(db_obj, Job):
- db_report.job_id = db_obj.id
+ db_report.job = db_obj
elif isinstance(db_obj, Task):
- db_report.task_id = db_obj.id
+ db_report.task = db_obj
elif isinstance(db_obj, Project):
- db_report.project_id = db_obj.id
+ db_report.project = db_obj
db_obj.analytics_report = db_report
@@ -369,7 +369,7 @@ def _compute_report_for_job(
data_extractor=None,
primary_statistics=primary_statistics[JobAnnotationSpeed.key()],
),
- JobTotalAnnotationSpeed(
+ JobAverageAnnotationSpeed(
db_job,
data_extractor=None,
primary_statistics=primary_statistics[JobAnnotationSpeed.key()],
@@ -392,12 +392,15 @@ def _compute_report_for_task(
data_extractors: dict,
) -> tuple[AnalyticsReport, list[AnalyticsReport]]:
job_reports = []
+
for db_segment in db_task.segment_set.all():
for db_job in db_segment.job_set.all():
- job_report = self._get_analytics_report(db_job)
+ current_job_report = self._get_analytics_report(db_job)
job_reports.append(
- self._compute_report_for_job(db_job, job_report, data_extractors)
+ self._compute_report_for_job(db_job, current_job_report, data_extractors)
)
+
+ filtered_job_reports = list(filter(lambda x: x.job.type == JobType.ANNOTATION, job_reports))
# recalculate the report if there is no report or the existing one is outdated
if db_report.created_date is None or db_report.created_date < db_task.updated_date:
derived_metrics = [
@@ -406,7 +409,7 @@ def _compute_report_for_task(
data_extractor=None,
primary_statistics=[
self._get_metric_by_key(JobObjects.key(), jr.statistics)
- for jr in job_reports
+ for jr in filtered_job_reports
],
),
TaskAnnotationSpeed(
@@ -414,7 +417,7 @@ def _compute_report_for_task(
data_extractor=None,
primary_statistics=[
self._get_metric_by_key(JobAnnotationSpeed.key(), jr.statistics)
- for jr in job_reports
+ for jr in filtered_job_reports
],
),
TaskAnnotationTime(
@@ -422,7 +425,7 @@ def _compute_report_for_task(
data_extractor=None,
primary_statistics=[
self._get_metric_by_key(JobAnnotationTime.key(), jr.statistics)
- for jr in job_reports
+ for jr in filtered_job_reports
],
),
TaskTotalObjectCount(
@@ -430,15 +433,15 @@ def _compute_report_for_task(
data_extractor=None,
primary_statistics=[
self._get_metric_by_key(JobAnnotationSpeed.key(), jr.statistics)
- for jr in job_reports
+ for jr in filtered_job_reports
],
),
- TaskTotalAnnotationSpeed(
+ TaskAverageAnnotationSpeed(
db_task,
data_extractor=None,
primary_statistics=[
self._get_metric_by_key(JobAnnotationSpeed.key(), jr.statistics)
- for jr in job_reports
+ for jr in filtered_job_reports
],
),
]
@@ -456,11 +459,16 @@ def _compute_report_for_project(
) -> tuple[AnalyticsReport, list[AnalyticsReport], list[AnalyticsReport]]:
job_reports = []
task_reports = []
+
for db_task in db_project.tasks.all():
- db_task_report = self._get_analytics_report(db_task)
- tr, jrs = self._compute_report_for_task(db_task, db_task_report, data_extractors)
- task_reports.append(tr)
- job_reports.extend(jrs)
+ current_task_report = self._get_analytics_report(db_task)
+ _task_report, _job_reports = self._compute_report_for_task(
+ db_task, current_task_report, data_extractors
+ )
+ task_reports.append(_task_report)
+ job_reports.extend(_job_reports)
+
+ filtered_job_reports = list(filter(lambda x: x.job.type == JobType.ANNOTATION, job_reports))
# recalculate the report if there is no report or the existing one is outdated
if db_report.created_date is None or db_report.created_date < db_project.updated_date:
derived_metrics = [
@@ -469,7 +477,7 @@ def _compute_report_for_project(
data_extractor=None,
primary_statistics=[
self._get_metric_by_key(JobObjects.key(), jr.statistics)
- for jr in job_reports
+ for jr in filtered_job_reports
],
),
ProjectAnnotationSpeed(
@@ -477,7 +485,7 @@ def _compute_report_for_project(
data_extractor=None,
primary_statistics=[
self._get_metric_by_key(JobAnnotationSpeed.key(), jr.statistics)
- for jr in job_reports
+ for jr in filtered_job_reports
],
),
ProjectAnnotationTime(
@@ -485,7 +493,7 @@ def _compute_report_for_project(
data_extractor=None,
primary_statistics=[
self._get_metric_by_key(JobAnnotationTime.key(), jr.statistics)
- for jr in job_reports
+ for jr in filtered_job_reports
],
),
ProjectTotalObjectCount(
@@ -493,15 +501,15 @@ def _compute_report_for_project(
data_extractor=None,
primary_statistics=[
self._get_metric_by_key(JobAnnotationSpeed.key(), jr.statistics)
- for jr in job_reports
+ for jr in filtered_job_reports
],
),
- ProjectTotalAnnotationSpeed(
+ ProjectAverageAnnotationSpeed(
db_project,
data_extractor=None,
primary_statistics=[
self._get_metric_by_key(JobAnnotationSpeed.key(), jr.statistics)
- for jr in job_reports
+ for jr in filtered_job_reports
],
),
]
diff --git a/cvat/apps/analytics_report/report/derived_metrics/__init__.py b/cvat/apps/analytics_report/report/derived_metrics/__init__.py
index f502235d1a0e..5dc81788809c 100644
--- a/cvat/apps/analytics_report/report/derived_metrics/__init__.py
+++ b/cvat/apps/analytics_report/report/derived_metrics/__init__.py
@@ -4,11 +4,11 @@
from .annotation_speed import ProjectAnnotationSpeed, TaskAnnotationSpeed
from .annotation_time import ProjectAnnotationTime, TaskAnnotationTime
+from .average_annotation_speed import (
+ JobAverageAnnotationSpeed,
+ ProjectAverageAnnotationSpeed,
+ TaskAverageAnnotationSpeed,
+)
from .base import DerivedMetricBase
from .objects import ProjectObjects, TaskObjects
-from .total_annotation_speed import (
- JobTotalAnnotationSpeed,
- ProjectTotalAnnotationSpeed,
- TaskTotalAnnotationSpeed,
-)
from .total_object_count import JobTotalObjectCount, ProjectTotalObjectCount, TaskTotalObjectCount
diff --git a/cvat/apps/analytics_report/report/derived_metrics/annotation_speed.py b/cvat/apps/analytics_report/report/derived_metrics/annotation_speed.py
index 5835c3774e0f..d00eb0948577 100644
--- a/cvat/apps/analytics_report/report/derived_metrics/annotation_speed.py
+++ b/cvat/apps/analytics_report/report/derived_metrics/annotation_speed.py
@@ -12,7 +12,7 @@
class TaskAnnotationSpeed(DerivedMetricBase, JobAnnotationSpeed):
- _description = "Metric shows the annotation speed in objects per hour for the Task."
+ _description = "Metric shows annotation speed in the task as number of objects per hour."
_query = None
def calculate(self):
@@ -52,4 +52,4 @@ def calculate(self):
class ProjectAnnotationSpeed(TaskAnnotationSpeed):
- _description = "Metric shows the annotation speed in objects per hour for the Project."
+ _description = "Metric shows annotation speed in the project as number of objects per hour."
diff --git a/cvat/apps/analytics_report/report/derived_metrics/total_annotation_speed.py b/cvat/apps/analytics_report/report/derived_metrics/average_annotation_speed.py
similarity index 61%
rename from cvat/apps/analytics_report/report/derived_metrics/total_annotation_speed.py
rename to cvat/apps/analytics_report/report/derived_metrics/average_annotation_speed.py
index ee0b24f7edae..37229ac4e32f 100644
--- a/cvat/apps/analytics_report/report/derived_metrics/total_annotation_speed.py
+++ b/cvat/apps/analytics_report/report/derived_metrics/average_annotation_speed.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2023 CVAT.ai Corporation
+# Copyright (C) 2023-2024 CVAT.ai Corporation
#
# SPDX-License-Identifier: MIT
@@ -7,11 +7,11 @@
from .base import DerivedMetricBase
-class JobTotalAnnotationSpeed(DerivedMetricBase):
- _title = "Total Annotation Speed (objects per hour)"
- _description = "Metric shows total annotation speed in the Job."
+class JobAverageAnnotationSpeed(DerivedMetricBase):
+ _title = "Average Annotation Speed (objects per hour)"
+ _description = "Metric shows average annotation speed in the Job."
_default_view = ViewChoice.NUMERIC
- _key = "total_annotation_speed"
+ _key = "average_annotation_speed"
_is_filterable_by_date = False
def calculate(self):
@@ -23,14 +23,12 @@ def calculate(self):
total_wt += ds[1]["value"]
metric = self.get_empty()
- metric["total_annotation_speed"][0]["value"] = (
- total_count / max(total_wt, 1) if total_wt != 0 else 0
- )
+ metric[self._key][0]["value"] = total_count / total_wt if total_wt != 0 else 0
return metric
def get_empty(self):
return {
- "total_annotation_speed": [
+ self._key: [
{
"value": 0,
"datetime": self._get_utc_now().strftime("%Y-%m-%dT%H:%M:%SZ"),
@@ -39,8 +37,8 @@ def get_empty(self):
}
-class TaskTotalAnnotationSpeed(JobTotalAnnotationSpeed):
- _description = "Metric shows total annotation speed in the Task."
+class TaskAverageAnnotationSpeed(JobAverageAnnotationSpeed):
+ _description = "Metric shows average annotation speed in the Task."
def calculate(self):
total_count = 0
@@ -53,14 +51,14 @@ def calculate(self):
total_wt += wt_entry["value"]
return {
- "total_annotation_speed": [
+ self._key: [
{
- "value": total_count / max(total_wt, 1) if total_wt != 0 else 0,
+ "value": total_count / total_wt if total_wt != 0 else 0,
"datetime": self._get_utc_now().strftime("%Y-%m-%dT%H:%M:%SZ"),
},
]
}
-class ProjectTotalAnnotationSpeed(TaskTotalAnnotationSpeed):
- _description = "Metric shows total annotation speed in the Project."
+class ProjectAverageAnnotationSpeed(TaskAverageAnnotationSpeed):
+ _description = "Metric shows average annotation speed in the Project."
diff --git a/cvat/apps/analytics_report/report/get.py b/cvat/apps/analytics_report/report/get.py
index 73674a370880..1bc2d699f289 100644
--- a/cvat/apps/analytics_report/report/get.py
+++ b/cvat/apps/analytics_report/report/get.py
@@ -39,19 +39,6 @@ def _convert_datetime_to_date(statistics):
return statistics
-def _clamp_working_time(statistics):
- affected_metrics = "annotation_speed"
- for metric in statistics:
- if metric["name"] not in affected_metrics:
- continue
- data_series = metric.get("data_series", {})
- if data_series:
- for df in data_series["working_time"]:
- df["value"] = max(df["value"], 1)
-
- return statistics
-
-
def _get_object_report(obj_model, pk, start_date, end_date):
data = {}
try:
@@ -65,7 +52,7 @@ def _get_object_report(obj_model, pk, start_date, end_date):
statistics = _filter_statistics_by_date(db_analytics_report.statistics, start_date, end_date)
statistics = _convert_datetime_to_date(statistics)
- data["statistics"] = _clamp_working_time(statistics)
+ data["statistics"] = statistics
data["created_date"] = db_analytics_report.created_date
if obj_model is Job:
diff --git a/cvat/apps/analytics_report/report/primary_metrics/annotation_speed.py b/cvat/apps/analytics_report/report/primary_metrics/annotation_speed.py
index b71188b38286..03ec983297e6 100644
--- a/cvat/apps/analytics_report/report/primary_metrics/annotation_speed.py
+++ b/cvat/apps/analytics_report/report/primary_metrics/annotation_speed.py
@@ -18,7 +18,7 @@
PrimaryMetricBase,
)
from cvat.apps.dataset_manager.task import merge_table_rows
-from cvat.apps.engine.models import SourceType
+from cvat.apps.engine.models import ShapeType, SourceType
class JobAnnotationSpeedExtractor(DataExtractorBase):
@@ -54,7 +54,7 @@ def __init__(
class JobAnnotationSpeed(PrimaryMetricBase):
_key = "annotation_speed"
_title = "Annotation speed (objects per hour)"
- _description = "Metric shows the annotation speed in objects per hour."
+ _description = "Metric shows annotation speed in the job as number of objects per hour."
_default_view = ViewChoice.HISTOGRAM
_granularity = GranularityChoice.DAY
_is_filterable_by_date = False
@@ -75,20 +75,21 @@ def get_tags_count():
def get_shapes_count():
return (
- self._db_obj.labeledshape_set.filter(parent=None)
- .exclude(source=SourceType.FILE)
+ self._db_obj.labeledshape_set.exclude(source=SourceType.FILE)
+ .exclude(
+ type=ShapeType.SKELETON
+ ) # skeleton's points are already counted as objects
.count()
)
def get_track_count():
db_tracks = (
- self._db_obj.labeledtrack_set.filter(parent=None)
- .exclude(source=SourceType.FILE)
+ self._db_obj.labeledtrack_set.exclude(source=SourceType.FILE)
.values(
"id",
- "source",
"trackedshape__id",
"trackedshape__frame",
+ "trackedshape__type",
"trackedshape__outside",
)
.order_by("id", "trackedshape__frame")
@@ -101,6 +102,7 @@ def get_track_count():
"shapes": [
"trackedshape__id",
"trackedshape__frame",
+ "trackedshape__type",
"trackedshape__outside",
],
},
@@ -109,12 +111,16 @@ def get_track_count():
count = 0
for track in db_tracks:
+ if track["shapes"] and track["shapes"][0]["type"] == ShapeType.SKELETON:
+ # skeleton's points are already counted as objects
+ continue
+
if len(track["shapes"]) == 1:
count += self._db_obj.segment.stop_frame - track["shapes"][0]["frame"] + 1
for prev_shape, cur_shape in zip(track["shapes"], track["shapes"][1:]):
- if prev_shape["outside"] is not True:
- count += cur_shape["frame"] - prev_shape["frame"]
+ if not prev_shape["outside"]:
+ count += cur_shape["frame"] - prev_shape["frame"] + 1
return count
@@ -137,27 +143,24 @@ def get_track_count():
if statistics is not None:
data_series = deepcopy(statistics["data_series"])
- last_entry_count = 0
+ previous_count = 0
if data_series["object_count"]:
- last_entry = data_series["object_count"][-1]
- last_entry_timestamp = parser.parse(last_entry["datetime"])
+ last_entry_timestamp = parser.parse(data_series["object_count"][-1]["datetime"])
if last_entry_timestamp.date() == timestamp.date():
# remove last entry, it will be re-calculated below, because of the same date
data_series["object_count"] = data_series["object_count"][:-1]
data_series["working_time"] = data_series["working_time"][:-1]
- if len(data_series["object_count"]):
- current_last_entry = data_series["object_count"][-1]
- start_datetime = parser.parse(current_last_entry["datetime"])
- last_entry_count = current_last_entry["value"]
- else:
- last_entry_count = last_entry["value"]
- start_datetime = parser.parse(last_entry["datetime"])
+ for entry in data_series["object_count"]:
+ previous_count += entry["value"]
+
+ if data_series["object_count"]:
+ start_datetime = parser.parse(data_series["object_count"][-1]["datetime"])
data_series["object_count"].append(
{
- "value": object_count - last_entry_count,
+ "value": object_count - previous_count,
"datetime": timestamp_str,
}
)
diff --git a/cvat/apps/engine/media_extractors.py b/cvat/apps/engine/media_extractors.py
index d6d306121527..9a352c3b930c 100644
--- a/cvat/apps/engine/media_extractors.py
+++ b/cvat/apps/engine/media_extractors.py
@@ -679,7 +679,8 @@ def save_as_chunk(self, images: Iterable[tuple[Image.Image|io.IOBase|str, str, s
else:
rot_image.save(
output,
- format=rot_image.format if rot_image.format else self.IMAGE_EXT,
+ # use format from original image, https://github.com/python-pillow/Pillow/issues/5527
+ format=image.format if image.format else self.IMAGE_EXT,
quality=100,
subsampling=0
)
diff --git a/cvat/apps/engine/serializers.py b/cvat/apps/engine/serializers.py
index 200e4b001126..37c06694dc5c 100644
--- a/cvat/apps/engine/serializers.py
+++ b/cvat/apps/engine/serializers.py
@@ -321,6 +321,16 @@ def validate(self, attrs):
return attrs
+ @staticmethod
+ def check_attribute_names_unique(attrs):
+ encountered_names = set()
+ for attribute in attrs:
+ attr_name = attribute.get('name')
+ if attr_name in encountered_names:
+ raise serializers.ValidationError(f"Duplicate attribute with name '{attr_name}' exists")
+ else:
+ encountered_names.add(attr_name)
+
@classmethod
@transaction.atomic
def update_label(
@@ -336,6 +346,8 @@ def update_label(
attributes = validated_data.pop('attributespec_set', [])
+ cls.check_attribute_names_unique(attributes)
+
if validated_data.get('id') is not None:
try:
db_label = models.Label.objects.get(id=validated_data['id'], **parent_info)
@@ -451,6 +463,8 @@ def create_labels(cls,
for label in labels:
attributes = label.pop('attributespec_set')
+ cls.check_attribute_names_unique(attributes)
+
if label.get('id', None):
del label['id']
@@ -734,19 +748,19 @@ def create(self, validated_data):
return job
def update(self, instance, validated_data):
- state = validated_data.get('state')
- stage = validated_data.get('stage')
- if stage:
+ stage = validated_data.get('stage', instance.stage)
+ state = validated_data.get('state', models.StateChoice.NEW if stage != instance.stage else instance.state)
+
+ if 'stage' in validated_data or 'state' in validated_data:
if stage == models.StageChoice.ANNOTATION:
- status = models.StatusChoice.ANNOTATION
+ validated_data['status'] = models.StatusChoice.ANNOTATION
elif stage == models.StageChoice.ACCEPTANCE and state == models.StateChoice.COMPLETED:
- status = models.StatusChoice.COMPLETED
+ validated_data['status'] = models.StatusChoice.COMPLETED
else:
- status = models.StatusChoice.VALIDATION
+ validated_data['status'] = models.StatusChoice.VALIDATION
- validated_data['status'] = status
- if stage != instance.stage and not state:
- validated_data['state'] = models.StateChoice.NEW
+ if state != instance.state:
+ validated_data['state'] = state
assignee = validated_data.get('assignee')
if assignee is not None:
diff --git a/cvat/nginx.conf b/cvat/nginx.conf
index 5c67e4b1acd1..392c49d61a30 100644
--- a/cvat/nginx.conf
+++ b/cvat/nginx.conf
@@ -47,6 +47,8 @@ http {
client_max_body_size 1G;
add_header X-Frame-Options deny;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+ add_header X-Content-Type-Options "nosniff" always;
server_name _;
diff --git a/cvat/schema.yml b/cvat/schema.yml
index 1474351f474c..b808e98dcf37 100644
--- a/cvat/schema.yml
+++ b/cvat/schema.yml
@@ -1,7 +1,7 @@
openapi: 3.0.3
info:
title: CVAT REST API
- version: 2.13.0
+ version: 2.14.0
description: REST API for Computer Vision Annotation Tool (CVAT)
termsOfService: https://www.google.com/policies/terms/
contact:
diff --git a/docker-compose.yml b/docker-compose.yml
index bcdf7ae547a3..84254e2ee2f5 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -72,7 +72,7 @@ services:
cvat_server:
container_name: cvat_server
- image: cvat/server:${CVAT_VERSION:-v2.13.0}
+ image: cvat/server:${CVAT_VERSION:-v2.14.0}
restart: always
depends_on:
<<: *backend-deps
@@ -106,7 +106,7 @@ services:
cvat_utils:
container_name: cvat_utils
- image: cvat/server:${CVAT_VERSION:-v2.13.0}
+ image: cvat/server:${CVAT_VERSION:-v2.14.0}
restart: always
depends_on: *backend-deps
environment:
@@ -123,7 +123,7 @@ services:
cvat_worker_import:
container_name: cvat_worker_import
- image: cvat/server:${CVAT_VERSION:-v2.13.0}
+ image: cvat/server:${CVAT_VERSION:-v2.14.0}
restart: always
depends_on: *backend-deps
environment:
@@ -139,7 +139,7 @@ services:
cvat_worker_export:
container_name: cvat_worker_export
- image: cvat/server:${CVAT_VERSION:-v2.13.0}
+ image: cvat/server:${CVAT_VERSION:-v2.14.0}
restart: always
depends_on: *backend-deps
environment:
@@ -155,7 +155,7 @@ services:
cvat_worker_annotation:
container_name: cvat_worker_annotation
- image: cvat/server:${CVAT_VERSION:-v2.13.0}
+ image: cvat/server:${CVAT_VERSION:-v2.14.0}
restart: always
depends_on: *backend-deps
environment:
@@ -171,7 +171,7 @@ services:
cvat_worker_webhooks:
container_name: cvat_worker_webhooks
- image: cvat/server:${CVAT_VERSION:-v2.13.0}
+ image: cvat/server:${CVAT_VERSION:-v2.14.0}
restart: always
depends_on: *backend-deps
environment:
@@ -187,7 +187,7 @@ services:
cvat_worker_quality_reports:
container_name: cvat_worker_quality_reports
- image: cvat/server:${CVAT_VERSION:-v2.13.0}
+ image: cvat/server:${CVAT_VERSION:-v2.14.0}
restart: always
depends_on: *backend-deps
environment:
@@ -203,7 +203,7 @@ services:
cvat_worker_analytics_reports:
container_name: cvat_worker_analytics_reports
- image: cvat/server:${CVAT_VERSION:-v2.13.0}
+ image: cvat/server:${CVAT_VERSION:-v2.14.0}
restart: always
depends_on: *backend-deps
environment:
@@ -219,7 +219,7 @@ services:
cvat_ui:
container_name: cvat_ui
- image: cvat/ui:${CVAT_VERSION:-v2.13.0}
+ image: cvat/ui:${CVAT_VERSION:-v2.14.0}
restart: always
depends_on:
- cvat_server
diff --git a/helm-chart/Chart.yaml b/helm-chart/Chart.yaml
index 93e98a2116f1..294297c2c282 100644
--- a/helm-chart/Chart.yaml
+++ b/helm-chart/Chart.yaml
@@ -16,7 +16,7 @@ type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
-version: 0.12.0
+version: 0.13.0
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
diff --git a/helm-chart/templates/_helpers.tpl b/helm-chart/templates/_helpers.tpl
index 38857c650412..9fe032f32551 100644
--- a/helm-chart/templates/_helpers.tpl
+++ b/helm-chart/templates/_helpers.tpl
@@ -51,14 +51,10 @@ app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
-Create the name of the service account to use
+The name of the service account to use for backend pods
*/}}
-{{- define "cvat.serviceAccountName" -}}
-{{- if .Values.serviceAccount.create }}
-{{- default (include "cvat.fullname" .) .Values.serviceAccount.name }}
-{{- else }}
-{{- default "default" .Values.serviceAccount.name }}
-{{- end }}
+{{- define "cvat.backend.serviceAccountName" -}}
+{{- default "default" .Values.cvat.backend.serviceAccount.name }}
{{- end }}
{{- define "cvat.sharedBackendEnv" }}
@@ -98,10 +94,14 @@ Create the name of the service account to use
- name: CVAT_POSTGRES_PORT
value: "{{ .Values.postgresql.service.ports.postgresql }}"
{{- else }}
+{{- if .Values.postgresql.external.host }}
- name: CVAT_POSTGRES_HOST
value: "{{ .Values.postgresql.external.host }}"
+{{- end }}
+{{- if .Values.postgresql.external.port }}
- name: CVAT_POSTGRES_PORT
value: "{{ .Values.postgresql.external.port }}"
+{{- end}}
{{- end }}
- name: CVAT_POSTGRES_USER
valueFrom:
diff --git a/helm-chart/templates/cvat_backend/initializer/job.yml b/helm-chart/templates/cvat_backend/initializer/job.yml
index 2ec6c723d206..c1304f987e67 100644
--- a/helm-chart/templates/cvat_backend/initializer/job.yml
+++ b/helm-chart/templates/cvat_backend/initializer/job.yml
@@ -37,6 +37,7 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
+ serviceAccountName: {{ include "cvat.backend.serviceAccountName" . }}
containers:
- name: cvat-backend
image: {{ .Values.cvat.backend.image }}:{{ .Values.cvat.backend.tag }}
diff --git a/helm-chart/templates/cvat_backend/server/deployment.yml b/helm-chart/templates/cvat_backend/server/deployment.yml
index 8de61fe2b692..8dff291957a2 100644
--- a/helm-chart/templates/cvat_backend/server/deployment.yml
+++ b/helm-chart/templates/cvat_backend/server/deployment.yml
@@ -45,6 +45,7 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
+ serviceAccountName: {{ include "cvat.backend.serviceAccountName" . }}
containers:
- name: cvat-backend
image: {{ .Values.cvat.backend.image }}:{{ .Values.cvat.backend.tag }}
@@ -57,8 +58,6 @@ spec:
env:
- name: ALLOWED_HOSTS
value: {{ $localValues.envs.ALLOWED_HOSTS | squote}}
- - name: DJANGO_MODWSGI_EXTRA_ARGS
- value: {{ $localValues.envs.DJANGO_MODWSGI_EXTRA_ARGS}}
{{ include "cvat.sharedBackendEnv" . | indent 10 }}
{{- with concat .Values.cvat.backend.additionalEnv $localValues.additionalEnv }}
{{- toYaml . | nindent 10 }}
diff --git a/helm-chart/templates/cvat_backend/utils/deployment.yml b/helm-chart/templates/cvat_backend/utils/deployment.yml
index 26ea0ee8936e..15229fafbd2f 100644
--- a/helm-chart/templates/cvat_backend/utils/deployment.yml
+++ b/helm-chart/templates/cvat_backend/utils/deployment.yml
@@ -45,6 +45,7 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
+ serviceAccountName: {{ include "cvat.backend.serviceAccountName" . }}
containers:
- name: cvat-backend
image: {{ .Values.cvat.backend.image }}:{{ .Values.cvat.backend.tag }}
diff --git a/helm-chart/templates/cvat_backend/worker_analyticsreports/deployment.yml b/helm-chart/templates/cvat_backend/worker_analyticsreports/deployment.yml
index 37b18d3cafed..b4ae5456fc2b 100644
--- a/helm-chart/templates/cvat_backend/worker_analyticsreports/deployment.yml
+++ b/helm-chart/templates/cvat_backend/worker_analyticsreports/deployment.yml
@@ -45,6 +45,7 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
+ serviceAccountName: {{ include "cvat.backend.serviceAccountName" . }}
containers:
- name: cvat-backend
image: {{ .Values.cvat.backend.image }}:{{ .Values.cvat.backend.tag }}
diff --git a/helm-chart/templates/cvat_backend/worker_annotation/deployment.yml b/helm-chart/templates/cvat_backend/worker_annotation/deployment.yml
index 4e681c0043f9..dedb86976e9f 100644
--- a/helm-chart/templates/cvat_backend/worker_annotation/deployment.yml
+++ b/helm-chart/templates/cvat_backend/worker_annotation/deployment.yml
@@ -45,6 +45,7 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
+ serviceAccountName: {{ include "cvat.backend.serviceAccountName" . }}
containers:
- name: cvat-backend
image: {{ .Values.cvat.backend.image }}:{{ .Values.cvat.backend.tag }}
diff --git a/helm-chart/templates/cvat_backend/worker_export/deployment.yml b/helm-chart/templates/cvat_backend/worker_export/deployment.yml
index a4e0b895c2dc..ab37061e7ddd 100644
--- a/helm-chart/templates/cvat_backend/worker_export/deployment.yml
+++ b/helm-chart/templates/cvat_backend/worker_export/deployment.yml
@@ -45,6 +45,7 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
+ serviceAccountName: {{ include "cvat.backend.serviceAccountName" . }}
containers:
- name: cvat-backend
image: {{ .Values.cvat.backend.image }}:{{ .Values.cvat.backend.tag }}
diff --git a/helm-chart/templates/cvat_backend/worker_import/deployment.yml b/helm-chart/templates/cvat_backend/worker_import/deployment.yml
index 3b751303cc52..b96b6b9b64a0 100644
--- a/helm-chart/templates/cvat_backend/worker_import/deployment.yml
+++ b/helm-chart/templates/cvat_backend/worker_import/deployment.yml
@@ -45,6 +45,7 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
+ serviceAccountName: {{ include "cvat.backend.serviceAccountName" . }}
containers:
- name: cvat-backend
image: {{ .Values.cvat.backend.image }}:{{ .Values.cvat.backend.tag }}
diff --git a/helm-chart/templates/cvat_backend/worker_qualityreports/deployment.yml b/helm-chart/templates/cvat_backend/worker_qualityreports/deployment.yml
index 7dabcf62b5f3..663dc7bc097f 100644
--- a/helm-chart/templates/cvat_backend/worker_qualityreports/deployment.yml
+++ b/helm-chart/templates/cvat_backend/worker_qualityreports/deployment.yml
@@ -45,6 +45,7 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
+ serviceAccountName: {{ include "cvat.backend.serviceAccountName" . }}
containers:
- name: cvat-backend
image: {{ .Values.cvat.backend.image }}:{{ .Values.cvat.backend.tag }}
diff --git a/helm-chart/templates/cvat_backend/worker_webhooks/deployment.yml b/helm-chart/templates/cvat_backend/worker_webhooks/deployment.yml
index e88153a1386c..9a6cc4a97751 100644
--- a/helm-chart/templates/cvat_backend/worker_webhooks/deployment.yml
+++ b/helm-chart/templates/cvat_backend/worker_webhooks/deployment.yml
@@ -45,6 +45,7 @@ spec:
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
+ serviceAccountName: {{ include "cvat.backend.serviceAccountName" . }}
containers:
- name: cvat-backend
image: {{ .Values.cvat.backend.image }}:{{ .Values.cvat.backend.tag }}
diff --git a/helm-chart/values.yaml b/helm-chart/values.yaml
index 238d88c37031..3003f0848089 100644
--- a/helm-chart/values.yaml
+++ b/helm-chart/values.yaml
@@ -17,6 +17,9 @@ cvat:
additionalEnv: []
additionalVolumes: []
additionalVolumeMounts: []
+ # -- The service account the backend pods will use to interact with the Kubernetes API
+ serviceAccount:
+ name: default
initializer:
labels: {}
@@ -36,7 +39,6 @@ cvat:
tolerations: []
envs:
ALLOWED_HOSTS: "*"
- DJANGO_MODWSGI_EXTRA_ARGS: ""
additionalEnv: []
additionalVolumes: []
additionalVolumeMounts: []
@@ -113,7 +115,7 @@ cvat:
additionalVolumeMounts: []
replicas: 1
image: cvat/server
- tag: v2.13.0
+ tag: v2.14.0
imagePullPolicy: Always
permissionFix:
enabled: true
@@ -137,7 +139,7 @@ cvat:
frontend:
replicas: 1
image: cvat/ui
- tag: v2.13.0
+ tag: v2.14.0
imagePullPolicy: Always
labels: {}
# test: test
@@ -275,9 +277,11 @@ postgresql:
#See https://github.com/bitnami/charts/blob/master/bitnami/postgresql/ for more info
enabled: true # false for external db
external:
- host: 127.0.0.1
- port: 5432
- # If not external following config will be applied by default
+ # Ignored if an empty value is set
+ host: ""
+ # Ignored if an empty value is set
+ port: ""
+ # If not external following config will be applied by default
auth:
existingSecret: "{{ .Release.Name }}-postgres-secret"
username: cvat
@@ -454,6 +458,9 @@ traefik:
RequestProtocol: keep
RouterName: keep
StartUTC: keep
+ providers:
+ kubernetesIngress:
+ allowEmptyServices: true
smokescreen:
opts: ''
diff --git a/tests/cypress.base.config.js b/tests/cypress.base.config.js
new file mode 100644
index 000000000000..84ecd7e84fc2
--- /dev/null
+++ b/tests/cypress.base.config.js
@@ -0,0 +1,24 @@
+// Copyright (C) 2024 CVAT.ai Corporation
+//
+// SPDX-License-Identifier: MIT
+const plugins = require('./cypress/plugins/index');
+
+module.exports = {
+ video: true,
+ viewportWidth: 1300,
+ viewportHeight: 960,
+ defaultCommandTimeout: 25000,
+ downloadsFolder: 'cypress/fixtures',
+ env: {
+ user: 'admin',
+ email: 'admin@localhost.company',
+ password: '12qwaszx',
+ },
+ e2e: {
+ setupNodeEvents(on, config) {
+ return plugins(on, config);
+ },
+ testIsolation: false,
+ baseUrl: 'http://localhost:8080',
+ },
+};
diff --git a/tests/cypress.config.js b/tests/cypress.config.js
index f6d327466adc..da1f20d8dee1 100644
--- a/tests/cypress.config.js
+++ b/tests/cypress.config.js
@@ -1,24 +1,11 @@
const { defineConfig } = require('cypress');
-const plugins = require('./cypress/plugins/index');
+const baseConfig = require('./cypress.base.config');
module.exports = defineConfig({
- video: true,
- viewportWidth: 1300,
- viewportHeight: 960,
- defaultCommandTimeout: 25000,
+ ...baseConfig,
numTestsKeptInMemory: 30, // reduce because out of memory issues
- downloadsFolder: 'cypress/fixtures',
- env: {
- user: 'admin',
- email: 'admin@localhost.company',
- password: '12qwaszx',
- },
e2e: {
- setupNodeEvents(on, config) {
- return plugins(on, config);
- },
- testIsolation: false,
- baseUrl: 'http://localhost:8080',
+ ...baseConfig.e2e,
specPattern: [
'cypress/e2e/auth_page.js',
'cypress/e2e/features/*.js',
diff --git a/tests/cypress/e2e/actions_objects/regression_tests.js b/tests/cypress/e2e/actions_objects/regression_tests.js
new file mode 100644
index 000000000000..d9026c1f2900
--- /dev/null
+++ b/tests/cypress/e2e/actions_objects/regression_tests.js
@@ -0,0 +1,85 @@
+// Copyright (C) 2024 CVAT.ai Corporation
+//
+// SPDX-License-Identifier: MIT
+
+///
+
+context('Regression tests', () => {
+ let taskID = null;
+ let jobID = null;
+
+ const taskPayload = {
+ name: 'Test annotations actions',
+ labels: [{
+ name: 'label 1',
+ attributes: [],
+ type: 'any',
+ }],
+ project_id: null,
+ source_storage: { location: 'local' },
+ target_storage: { location: 'local' },
+ };
+
+ const dataPayload = {
+ server_files: ['bigArchive.zip'],
+ image_quality: 70,
+ use_zip_chunks: true,
+ use_cache: true,
+ sorting_method: 'lexicographical',
+ };
+
+ const rectanglePayload = {
+ frame: 99,
+ objectType: 'SHAPE',
+ shapeType: 'RECTANGLE',
+ points: [250, 64, 491, 228],
+ occluded: false,
+ };
+
+ before(() => {
+ cy.visit('auth/login');
+ cy.login();
+
+ cy.headlessCreateTask(taskPayload, dataPayload).then((response) => {
+ taskID = response.taskID;
+ [jobID] = response.jobIDs;
+
+ cy.headlessCreateObject([rectanglePayload], jobID);
+ cy.visit(`/tasks/${taskID}/jobs/${jobID}`);
+ });
+ });
+
+ describe('Regression tests', () => {
+ it('UI does not crash if to activate an object while frame fetching', () => {
+ cy.reload();
+ cy.get('.cvat-player-last-button').click();
+
+ cy.intercept('GET', '/api/jobs/**/data?**', (req) => {
+ req.continue((res) => {
+ res.setDelay(1000);
+ });
+ }).as('delayedRequest');
+
+ cy.get('#cvat_canvas_shape_1').trigger('mousemove');
+ cy.get('#cvat_canvas_shape_1').should('not.have.class', 'cvat_canvas_shape_activated');
+
+ cy.wait('@delayedRequest');
+ cy.get('#cvat_canvas_shape_1').trigger('mousemove');
+ cy.get('#cvat_canvas_shape_1').should('have.class', 'cvat_canvas_shape_activated');
+ });
+ });
+
+ after(() => {
+ cy.logout();
+ cy.getAuthKey().then((response) => {
+ const authKey = response.body.key;
+ cy.request({
+ method: 'DELETE',
+ url: `/api/tasks/${taskID}`,
+ headers: {
+ Authorization: `Token ${authKey}`,
+ },
+ });
+ });
+ });
+});
diff --git a/tests/cypress/e2e/features/analytics_pipeline.js b/tests/cypress/e2e/features/analytics_pipeline.js
index f8a5ba46927b..f6c96c844a19 100644
--- a/tests/cypress/e2e/features/analytics_pipeline.js
+++ b/tests/cypress/e2e/features/analytics_pipeline.js
@@ -52,7 +52,7 @@ context('Analytics pipeline', () => {
},
];
- const cardEntryNames = ['annotation_time', 'total_object_count', 'total_annotation_speed'];
+ const cardEntryNames = ['annotation_time', 'total_object_count', 'average_annotation_speed'];
function checkCards() {
cy.get('.cvat-analytics-card')
.should('have.length', 3)
@@ -61,7 +61,7 @@ context('Analytics pipeline', () => {
.invoke('data', 'entry-name')
.then((val) => {
expect(cardEntryNames.includes(val)).to.eq(true);
- if (['total_object_count', 'total_annotation_speed'].includes(val)) {
+ if (['total_object_count', 'average_annotation_speed'].includes(val)) {
cy.wrap(card).within(() => {
cy.get('.cvat-analytics-card-value').should('not.have.text', '0.0');
});
diff --git a/tests/cypress/support/commands.js b/tests/cypress/support/commands.js
index da393a99df70..42f858b881ce 100644
--- a/tests/cypress/support/commands.js
+++ b/tests/cypress/support/commands.js
@@ -265,6 +265,29 @@ Cypress.Commands.add('headlessLogin', (username = Cypress.env('user'), password
});
});
+Cypress.Commands.add('headlessCreateObject', (objects, jobID) => {
+ cy.window().then(async ($win) => {
+ const job = (await $win.cvat.jobs.get({ jobID }))[0];
+ await job.annotations.clear(true);
+
+ const objectStates = objects
+ .map((object) => new $win.cvat.classes
+ .ObjectState({
+ frame: object.frame,
+ objectType: $win.cvat.enums.ObjectType[object.objectType],
+ shapeType: $win.cvat.enums.ShapeType[object.shapeType],
+ points: $win.Array.from(object.points),
+ occluded: object.occluded,
+ label: job.labels[0],
+ zOrder: 0,
+ }));
+
+ await job.annotations.put($win.Array.from(objectStates));
+ await job.annotations.save();
+ return cy.wrap();
+ });
+});
+
Cypress.Commands.add('headlessCreateTask', (taskSpec, dataSpec) => {
cy.window().then(async ($win) => {
const task = new $win.cvat.classes.Task({
diff --git a/tests/cypress_canvas3d.config.js b/tests/cypress_canvas3d.config.js
index 95239f1eb1c6..e1cd5ede69f2 100644
--- a/tests/cypress_canvas3d.config.js
+++ b/tests/cypress_canvas3d.config.js
@@ -1,23 +1,10 @@
const { defineConfig } = require('cypress');
-const plugins = require('./cypress/plugins/index');
+const baseConfig = require('./cypress.base.config');
module.exports = defineConfig({
- video: false,
- viewportWidth: 1300,
- viewportHeight: 960,
- defaultCommandTimeout: 25000,
- downloadsFolder: 'cypress/fixtures',
- env: {
- user: 'admin',
- email: 'admin@localhost.company',
- password: '12qwaszx',
- },
+ ...baseConfig,
e2e: {
- setupNodeEvents(on, config) {
- return plugins(on, config);
- },
- testIsolation: false,
- baseUrl: 'http://localhost:8080',
+ ...baseConfig.e2e,
specPattern: [
'cypress/e2e/auth_page.js',
'cypress/e2e/canvas3d_functionality/*.js',
diff --git a/tests/mounted_file_share/bigArchive.zip b/tests/mounted_file_share/bigArchive.zip
new file mode 100644
index 000000000000..2d24be45a9c4
Binary files /dev/null and b/tests/mounted_file_share/bigArchive.zip differ
diff --git a/tests/nightly_cypress.config.js b/tests/nightly_cypress.config.js
index 9a735f7a76bd..f9312e790661 100644
--- a/tests/nightly_cypress.config.js
+++ b/tests/nightly_cypress.config.js
@@ -1,23 +1,10 @@
const { defineConfig } = require('cypress');
-const plugins = require('./cypress/plugins/index');
+const baseConfig = require('./cypress.base.config');
module.exports = defineConfig({
- video: false,
- viewportWidth: 1300,
- viewportHeight: 960,
- defaultCommandTimeout: 25000,
- downloadsFolder: 'cypress/fixtures',
- env: {
- user: 'admin',
- email: 'admin@localhost.company',
- password: '12qwaszx',
- },
+ ...baseConfig,
e2e: {
- setupNodeEvents(on, config) {
- return plugins(on, config);
- },
- testIsolation: false,
- baseUrl: 'http://localhost:8080',
+ ...baseConfig.e2e,
specPattern: [
'cypress/e2e/auth_page.js',
'cypress/e2e/features/*.js',