Skip to content

Commit

Permalink
Merge branch 'develop' into kl/improve-quality-ux
Browse files Browse the repository at this point in the history
  • Loading branch information
klakhov authored Jan 9, 2025
2 parents 3ed00eb + b594b1c commit a3b7cbc
Show file tree
Hide file tree
Showing 237 changed files with 4,335 additions and 2,758 deletions.
30 changes: 3 additions & 27 deletions .github/workflows/isort.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- id: files
uses: tj-actions/[email protected]
with:
files: |
cvat-sdk/**/*.py
cvat-cli/**/*.py
tests/python/**/*.py
cvat/apps/quality_control/**/*.py
cvat/apps/analytics_report/**/*.py
dir_names: true

- name: Run checks
run: |
# If different modules use different isort configs,
# we need to run isort for each python component group separately.
# Otherwise, they all will use the same config.
pipx install $(grep "^isort" ./dev/requirements.txt)
UPDATED_DIRS="${{steps.files.outputs.all_changed_files}}"
echo "isort version: $(isort --version-number)"
if [[ ! -z $UPDATED_DIRS ]]; then
pipx install $(grep "^isort" ./dev/requirements.txt)
echo "isort version: $(isort --version-number)"
echo "The dirs will be checked: $UPDATED_DIRS"
EXIT_CODE=0
for DIR in $UPDATED_DIRS; do
isort --check $DIR || EXIT_CODE=$(($? | $EXIT_CODE)) || true
done
exit $EXIT_CODE
else
echo "No files with the \"py\" extension found"
fi
isort --check --diff --resolve-all-configs .
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ __pycache__
.coverage
.husky/
.python-version
tmp*cvat/
temp*/

# Ignore generated test files
docker-compose.tests.yml

# Ignore npm logs file
npm-debug.log*
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/20241229_221630_mzhiltso_empty_is_annotated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Changed

- The `match_empty_frames` quality setting is changed to `empty_is_annotated`.
The updated option includes any empty frames in the final metrics instead of only
matching empty frames. This makes metrics such as Precision much more representative and useful.
(<https://github.com/cvat-ai/cvat/pull/8888>)
4 changes: 4 additions & 0 deletions changelog.d/20250107_163424_dmitrii.lavrukhin_yolo_tracks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added

- Ultralytics YOLO formats now support tracks
(<https://github.com/cvat-ai/cvat/pull/8883>)
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Fixed

- Changing rotation after export/import in Ultralytics YOLO Oriented Boxes format
(<https://github.com/cvat-ai/cvat/pull/8891>)
124 changes: 92 additions & 32 deletions cvat-core/src/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import config from './config';

// frame storage by job id
const frameDataCache: Record<string, {
meta: FramesMetaData;
metaFetchedTimestamp: number;
chunkSize: number;
mode: 'annotation' | 'interpolation';
Expand All @@ -36,11 +35,21 @@ const frameDataCache: Record<string, {
size: number;
}>;
getChunk: (chunkIndex: number, quality: ChunkQuality) => Promise<ArrayBuffer>;
getMeta: () => Promise<FramesMetaData>;
}> = {};

// frame meta data storage by job id
const frameMetaCache: Record<string, Promise<FramesMetaData>> = {};

enum DeletedFrameState {
DELETED = 'deleted',
RESTORED = 'restored',
}

interface FramesMetaDataUpdatedData {
deletedFrames: Record<number, DeletedFrameState>;
}

export class FramesMetaData {
public chunkSize: number;
public deletedFrames: Record<number, boolean>;
Expand Down Expand Up @@ -82,10 +91,13 @@ export class FramesMetaData {
if (Object.prototype.hasOwnProperty.call(data, property) && property in initialData) {
if (property === 'deleted_frames') {
const update = (frame: string, remove: boolean): void => {
if (this.#updateTrigger.get(`deletedFrames:${frame}:${!remove}`)) {
this.#updateTrigger.resetField(`deletedFrames:${frame}:${!remove}`);
const [state, oppositeState] = remove ?
[DeletedFrameState.DELETED, DeletedFrameState.RESTORED] :
[DeletedFrameState.RESTORED, DeletedFrameState.DELETED];
if (this.#updateTrigger.get(`deletedFrames:${frame}:${oppositeState}`)) {
this.#updateTrigger.resetField(`deletedFrames:${frame}:${oppositeState}`);
} else {
this.#updateTrigger.update(`deletedFrames:${frame}:${remove}`);
this.#updateTrigger.update(`deletedFrames:${frame}:${state}`);
}
};

Expand Down Expand Up @@ -178,8 +190,17 @@ export class FramesMetaData {
return (dataFrameNumber - this.startFrame) / this.frameStep;
}

getUpdated(): Record<string, unknown> {
return this.#updateTrigger.getUpdated(this);
getUpdated(): FramesMetaDataUpdatedData {
const updatedFields = this.#updateTrigger.getUpdated(this);
const deletedFrames: FramesMetaDataUpdatedData['deletedFrames'] = {};
for (const key in updatedFields) {
if (Object.hasOwn(updatedFields, key) && key.startsWith('deletedFrames')) {
const [, frame, state] = key.split(':');
deletedFrames[frame] = state;
}
}

return { deletedFrames };
}

resetUpdated(): void {
Expand Down Expand Up @@ -340,17 +361,18 @@ class PrefetchAnalyzer {
}

Object.defineProperty(FrameData.prototype.data, 'implementation', {
value(this: FrameData, onServerRequest) {
async value(this: FrameData, onServerRequest) {
const {
provider, prefetchAnalyzer, chunkSize, jobStartFrame,
decodeForward, forwardStep, decodedBlocksCacheSize,
} = frameDataCache[this.jobID];
const meta = await frameDataCache[this.jobID].getMeta();

return new Promise<{
renderWidth: number;
renderHeight: number;
imageData: ImageBitmap | Blob;
} | Blob>((resolve, reject) => {
const {
meta, provider, prefetchAnalyzer, chunkSize, jobStartFrame,
decodeForward, forwardStep, decodedBlocksCacheSize,
} = frameDataCache[this.jobID];

const requestId = +_.uniqueId();
const requestedDataFrameNumber = meta.getDataFrameNumber(this.number - jobStartFrame);
const chunkIndex = meta.getFrameChunkIndex(requestedDataFrameNumber);
Expand Down Expand Up @@ -536,6 +558,34 @@ Object.defineProperty(FrameData.prototype.data, 'implementation', {
writable: false,
});

function mergeMetaData(
nextData: SerializedFramesMetaData,
previousData?: Promise<FramesMetaData>,
): Promise<FramesMetaData> {
const framesMetaData = new FramesMetaData({
...nextData,
deleted_frames: Object.fromEntries(nextData.deleted_frames.map((_frame) => [_frame, true])),
});

if (previousData instanceof Promise) {
return previousData.then((prevMeta) => {
const updatedFields = prevMeta.getUpdated();
const updatedDeletedFrames = updatedFields.deletedFrames;
for (const [frame, state] of Object.entries(updatedDeletedFrames)) {
if (state === DeletedFrameState.DELETED) {
framesMetaData.deletedFrames[frame] = true;
} else if (state === DeletedFrameState.RESTORED) {
delete framesMetaData.deletedFrames[frame];
}
}

return framesMetaData;
});
}

return Promise.resolve(framesMetaData);
}

export function getFramesMeta(type: 'job' | 'task', id: number, forceReload = false): Promise<FramesMetaData> {
if (type === 'task') {
// we do not cache task meta currently. So, each new call will results to the server request
Expand All @@ -551,11 +601,11 @@ export function getFramesMeta(type: 'job' | 'task', id: number, forceReload = fa
const previousCache = frameMetaCache[id];
frameMetaCache[id] = new Promise((resolve, reject) => {
serverProxy.frames.getMeta('job', id).then((serialized) => {
const framesMetaData = new FramesMetaData({
...serialized,
deleted_frames: Object.fromEntries(serialized.deleted_frames.map((_frame) => [_frame, true])),
// When we get new framesMetaData from server there can be some unsaved data
// here we merge new meta data with cached one
mergeMetaData(serialized, previousCache).then((mergedData) => {
resolve(mergedData);
});
resolve(framesMetaData);
}).catch((error: unknown) => {
delete frameMetaCache[id];
if (previousCache instanceof Promise) {
Expand Down Expand Up @@ -588,8 +638,9 @@ function saveJobMeta(meta: FramesMetaData, jobID: number): Promise<FramesMetaDat
return frameMetaCache[jobID];
}

function getFrameMeta(jobID, frame): SerializedFramesMetaData['frames'][0] {
const { meta, mode, jobStartFrame } = frameDataCache[jobID];
async function getFrameMeta(jobID, frame): Promise<SerializedFramesMetaData['frames'][0]> {
const { mode, jobStartFrame } = frameDataCache[jobID];
const meta = await frameDataCache[jobID].getMeta();
let frameMeta = null;
if (mode === 'interpolation' && meta.frames.length === 1) {
// video tasks have 1 frame info, but image tasks will have many infos
Expand All @@ -616,12 +667,12 @@ async function refreshJobCacheIfOutdated(jobID: number): Promise<void> {

if (isOutdated) {
// get metadata again if outdated
const prevMeta = await cached.getMeta();
const meta = await getFramesMeta('job', jobID, true);
if (new Date(meta.chunksUpdatedDate) > new Date(cached.meta.chunksUpdatedDate)) {
if (new Date(meta.chunksUpdatedDate) > new Date(prevMeta.chunksUpdatedDate)) {
// chunks were re-defined. Existing data not relevant anymore
// currently we only re-write meta, remove all cached frames from provider and clear cached context images
// other parameters (e.g. chunkSize) are not supposed to be changed
cached.meta = meta;
cached.provider.cleanup(Number.MAX_SAFE_INTEGER);
for (const frame of Object.keys(cached.contextCache)) {
for (const image of Object.values(cached.contextCache[+frame].data)) {
Expand All @@ -636,19 +687,19 @@ async function refreshJobCacheIfOutdated(jobID: number): Promise<void> {
}
}

export function getContextImage(jobID: number, frame: number): Promise<Record<string, ImageBitmap>> {
export async function getContextImage(jobID: number, frame: number): Promise<Record<string, ImageBitmap>> {
const frameData = frameDataCache[jobID];
const meta = await frameData.getMeta();
const requestId = frame;
const { jobStartFrame } = frameData;
const { related_files: relatedFiles } = meta.frames[frame - jobStartFrame];
return new Promise<Record<string, ImageBitmap>>((resolve, reject) => {
if (!(jobID in frameDataCache)) {
reject(new Error(
'Frame data was not initialized for this job. Try first requesting any frame.',
));
}

const frameData = frameDataCache[jobID];
const requestId = frame;
const { jobStartFrame } = frameData;
const { related_files: relatedFiles } = frameData.meta.frames[frame - jobStartFrame];

if (relatedFiles === 0) {
resolve({});
} else if (frame in frameData.contextCache) {
Expand Down Expand Up @@ -761,7 +812,6 @@ export async function getFrame(
);

frameDataCache[jobID] = {
meta,
metaFetchedTimestamp: Date.now(),
chunkSize,
mode,
Expand All @@ -784,6 +834,13 @@ export async function getFrame(
latestContextImagesRequest: null,
contextCache: {},
getChunk,
getMeta: () => {
const cached = frameMetaCache[jobID];
if (!(cached instanceof Promise)) {
throw new Error('Frame meta data is not initialized');
}
return cached;
},
};
}

Expand All @@ -803,25 +860,27 @@ export async function getFrame(
// Thus, it is better to only call `refreshJobCacheIfOutdated` from getFrame()
await refreshJobCacheIfOutdated(jobID);

const frameMeta = getFrameMeta(jobID, frame);
const frameMeta = await getFrameMeta(jobID, frame);
frameDataCache[jobID].provider.setRenderSize(frameMeta.width, frameMeta.height);
frameDataCache[jobID].decodeForward = isPlaying;
frameDataCache[jobID].forwardStep = step;

const meta = await frameDataCache[jobID].getMeta();

return new FrameData({
width: frameMeta.width,
height: frameMeta.height,
name: frameMeta.name,
related_files: frameMeta.related_files,
frameNumber: frame,
deleted: frame in frameDataCache[jobID].meta.deletedFrames,
deleted: frame in meta.deletedFrames,
jobID,
});
}

export async function getDeletedFrames(instanceType: 'job' | 'task', id: number): Promise<Record<number, boolean>> {
if (instanceType === 'job') {
const { meta } = frameDataCache[id];
const meta = await frameDataCache[id].getMeta();
return meta.deletedFrames;
}

Expand Down Expand Up @@ -900,12 +959,13 @@ export function getCachedChunks(jobID: number): number[] {
return frameDataCache[jobID].provider.cachedChunks(true);
}

export function getJobFrameNumbers(jobID: number): number[] {
export async function getJobFrameNumbers(jobID: number): Promise<number[]> {
if (!(jobID in frameDataCache)) {
return [];
}

const { meta, jobStartFrame } = frameDataCache[jobID];
const { jobStartFrame } = frameDataCache[jobID];
const meta = await frameDataCache[jobID].getMeta();
return meta.getSegmentFrameNumbers(jobStartFrame);
}

Expand Down
14 changes: 7 additions & 7 deletions cvat-core/src/quality-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export default class QualitySettings {
#objectVisibilityThreshold: number;
#panopticComparison: boolean;
#compareAttributes: boolean;
#matchEmptyFrames: boolean;
#emptyIsAnnotated: boolean;
#descriptions: Record<string, string>;

constructor(initialData: SerializedQualitySettingsData) {
Expand All @@ -60,7 +60,7 @@ export default class QualitySettings {
this.#objectVisibilityThreshold = initialData.object_visibility_threshold;
this.#panopticComparison = initialData.panoptic_comparison;
this.#compareAttributes = initialData.compare_attributes;
this.#matchEmptyFrames = initialData.match_empty_frames;
this.#emptyIsAnnotated = initialData.empty_is_annotated;
this.#descriptions = initialData.descriptions;
}

Expand Down Expand Up @@ -200,12 +200,12 @@ export default class QualitySettings {
this.#maxValidationsPerJob = newVal;
}

get matchEmptyFrames(): boolean {
return this.#matchEmptyFrames;
get emptyIsAnnotated(): boolean {
return this.#emptyIsAnnotated;
}

set matchEmptyFrames(newVal: boolean) {
this.#matchEmptyFrames = newVal;
set emptyIsAnnotated(newVal: boolean) {
this.#emptyIsAnnotated = newVal;
}

get descriptions(): Record<string, string> {
Expand Down Expand Up @@ -236,7 +236,7 @@ export default class QualitySettings {
target_metric: this.#targetMetric,
target_metric_threshold: this.#targetMetricThreshold,
max_validations_per_job: this.#maxValidationsPerJob,
match_empty_frames: this.#matchEmptyFrames,
empty_is_annotated: this.#emptyIsAnnotated,
};

return result;
Expand Down
2 changes: 1 addition & 1 deletion cvat-core/src/server-response-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ export interface SerializedQualitySettingsData {
object_visibility_threshold?: number;
panoptic_comparison?: boolean;
compare_attributes?: boolean;
match_empty_frames?: boolean;
empty_is_annotated?: boolean;
descriptions?: Record<string, string>;
}

Expand Down
2 changes: 1 addition & 1 deletion cvat-core/src/session-implementation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ export function implementJob(Job: typeof JobClass): typeof JobClass {
value: function includedFramesImplementation(
this: JobClass,
): ReturnType<typeof JobClass.prototype.frames.frameNumbers> {
return Promise.resolve(getJobFrameNumbers(this.id));
return getJobFrameNumbers(this.id);
},
});

Expand Down
1 change: 1 addition & 0 deletions cvat-sdk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ profile = "black"
forced_separate = ["tests"]
line_length = 100
skip_gitignore = true # align tool behavior with Black
known_first_party = ["cvat_sdk"]
Loading

0 comments on commit a3b7cbc

Please sign in to comment.