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',