Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { AnnotatorModes } from './annotator-modes/annotator-modes-toggle.compone
import { PredictionInferenceDevices } from './annotator-modes/prediction-inference-devices.component';
import { PredictionModelSelector } from './annotator-modes/prediction-model-selector.component';
import { PredictionButtons } from './annotator-modes/predictions-buttons.component';
import { useIsSubmitDisabled } from './use-is-submit-disabled.hook';
import { getNextItem } from './util';

import classes from './secondary-toolbar.module.scss';
Expand Down Expand Up @@ -95,7 +96,7 @@ export const SecondaryToolbar = ({
const { selectableModels } = usePredictionSetup();
const isPlaying = videoPlayerContext?.videoControls?.isPlaying ?? false;

const { canSubmit, isSaving, submitAnnotations, submitPredictions, initialAnnotations, initialPredictions } =
const { isSaving, submitAnnotations, submitPredictions, initialAnnotations, initialPredictions } =
useAnnotationActions();

const handleSubmit = async () => {
Expand All @@ -121,8 +122,11 @@ export const SecondaryToolbar = ({
const isPredictionMode = mode === 'prediction';
const isAnnotationMode = mode === 'annotation';

// If annotations are not changed but subset has changed we want to allow user to submit
const isSubmitDisabled = (!canSubmit && !hasSubsetChanged) || isSaving || isLoadingPredictions;
const isSubmitDisabled = useIsSubmitDisabled({
mode,
hasSubsetChanged,
isLoadingPredictions,
});

useHotkeys(
HOTKEYS.submit,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (C) 2026 Intel Corporation
// SPDX-License-Identifier: Apache-2.0

import { type ReactNode } from 'react';

import { waitFor } from '@testing-library/react';
import { getMockedShape } from 'mocks/mock-annotation';
import { getMockedLabel } from 'mocks/mock-labels';
import { getMockedMediaImage } from 'mocks/mock-media';
import { getMockedProject } from 'mocks/mock-project';
import { HttpResponse } from 'msw';

import { http } from '../../../../api/utils';
import type { AnnotationDTO, Label } from '../../../../constants/shared-types';
import { server } from '../../../../msw-node-setup';
import { AnnotationActionsProvider } from '../../../../shared/annotator/annotation-actions-provider.component';
import type { AnnotatorMode } from '../../../../shared/annotator/annotator-mode';
import { renderHook } from '../../../../test-utils/render';
import { useIsSubmitDisabled } from './use-is-submit-disabled.hook';

type RenderIsSubmitDisabledParams = {
mode?: AnnotatorMode;
hasSubsetChanged?: boolean;
isLoadingPredictions?: boolean;
initialAnnotationsDTO?: AnnotationDTO[];
initialPredictionsDTO?: AnnotationDTO[];
labels?: Label[];
};

const renderIsSubmitDisabled = ({
mode = 'annotation',
hasSubsetChanged = false,
isLoadingPredictions = false,
initialAnnotationsDTO = [],
initialPredictionsDTO = [],
labels = [],
}: RenderIsSubmitDisabledParams) => {
server.use(
http.get('/api/projects/{project_id}', () =>
HttpResponse.json(getMockedProject({ task: { task_type: 'detection', exclusive_labels: false, labels } }))
)
);

const wrapper = ({ children }: { children: ReactNode }) => (
<AnnotationActionsProvider
mode={mode}
mediaItem={getMockedMediaImage()}
initialAnnotationsDTO={initialAnnotationsDTO}
initialPredictionsDTO={initialPredictionsDTO}
>
{children}
</AnnotationActionsProvider>
);

return renderHook(() => useIsSubmitDisabled({ mode, hasSubsetChanged, isLoadingPredictions }), { wrapper });
};

describe('useIsSubmitDisabled', () => {
const label1 = getMockedLabel({ id: 'label-1', name: 'Cat', color: '#FF0000' });

it('annotation mode: disabled when annotations and subset are both unchanged', async () => {
const annotationsDTO: AnnotationDTO[] = [
{ labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
];

const { result } = renderIsSubmitDisabled({
mode: 'annotation',
labels: [label1],
initialAnnotationsDTO: annotationsDTO,
hasSubsetChanged: false,
});

await waitFor(() => expect(result.current).toBe(true));
});

it('annotation mode: enabled when annotations are unchanged but subset changed', async () => {
const annotationsDTO: AnnotationDTO[] = [
{ labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
];

const { result } = renderIsSubmitDisabled({
mode: 'annotation',
labels: [label1],
initialAnnotationsDTO: annotationsDTO,
hasSubsetChanged: true,
});

await waitFor(() => expect(result.current).toBe(false));
});

it('prediction mode: enabled when a prediction is present', async () => {
const predictionsDTO: AnnotationDTO[] = [
{ labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.9] },
];

const { result } = renderIsSubmitDisabled({
mode: 'prediction',
labels: [label1],
initialPredictionsDTO: predictionsDTO,
});

await waitFor(() => expect(result.current).toBe(false));
});

it('prediction mode: disabled while loading predictions, even with a prediction present', async () => {
const predictionsDTO: AnnotationDTO[] = [
{ labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.9] },
];

const { result } = renderIsSubmitDisabled({
mode: 'prediction',
labels: [label1],
initialPredictionsDTO: predictionsDTO,
isLoadingPredictions: true,
});

await waitFor(() => expect(result.current).toBe(true));
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (C) 2025-2026 Intel Corporation
// SPDX-License-Identifier: Apache-2.0

import { useAnnotationActions } from '../../../../shared/annotator/annotation-actions-provider.component';
import type { AnnotatorMode } from '../../../../shared/annotator/annotator-mode';

type UseIsSubmitDisabledParams = {
mode: AnnotatorMode;
hasSubsetChanged: boolean;
isLoadingPredictions: boolean;
};

export const useIsSubmitDisabled = ({
mode,
hasSubsetChanged,
isLoadingPredictions,
}: UseIsSubmitDisabledParams): boolean => {
const { canSubmit, hasInvalidAnnotation, isSaving } = useAnnotationActions();

const isContentSubmittable =
mode === 'prediction' ? canSubmit : !hasInvalidAnnotation && (canSubmit || hasSubsetChanged);

return !isContentSubmittable || isSaving || isLoadingPredictions;
};
Original file line number Diff line number Diff line change
Expand Up @@ -196,3 +196,54 @@ describe('Label normalization', () => {
});
});
});

describe('canSubmit on initial load', () => {
it('is false when initial annotations are unchanged, including a null confidences field from the server', async () => {
const label1 = getMockedLabel({ id: 'label-1', name: 'Cat', color: '#FF0000' });

// This is the exact shape the backend returns for a human annotation with no confidence
// scores: `confidences` is present and `null` (see DatasetItemAnnotation), never omitted.
const annotationsDTO: AnnotationDTO[] = [
{ labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
];

const { result } = renderAnnotationActions({
mode: 'annotation',
labels: [label1],
initialAnnotationsDTO: annotationsDTO,
});

await waitFor(() => expect(result.current).not.toBeNull());

await waitFor(() => {
expect(result.current.annotations).toHaveLength(1);
expect(result.current.canSubmit).toBe(false);
});
});

it('is true once an unchanged annotation set is actually edited', async () => {
const label1 = getMockedLabel({ id: 'label-1', name: 'Cat', color: '#FF0000' });
const label2 = getMockedLabel({ id: 'label-2', name: 'Dog', color: '#00FF00' });

const annotationsDTO: AnnotationDTO[] = [
{ labels: [{ id: label1.id }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
];

const { result } = renderAnnotationActions({
mode: 'annotation',
labels: [label1, label2],
initialAnnotationsDTO: annotationsDTO,
});

await waitFor(() => expect(result.current.canSubmit).toBe(false));

act(() => {
result.current.addAnnotations([getMockedShape({ type: 'rectangle' })], [{ id: label2.id }]);
});

await waitFor(() => {
expect(result.current.annotations).toHaveLength(2);
expect(result.current.canSubmit).toBe(true);
});
});
});
111 changes: 111 additions & 0 deletions application/ui/src/shared/annotator/annotation-mappers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (C) 2025-2026 Intel Corporation
// SPDX-License-Identifier: Apache-2.0

import { getMockedShape } from 'mocks/mock-annotation';
import { getMockedAnnotationLabelRef } from 'mocks/mock-labels';

import type { AnnotationDTO } from '../../constants/shared-types';
import { mapLocalAnnotationsToServer, mapServerAnnotationsToLocal } from './annotation-mappers';

describe('mapServerAnnotationsToLocal', () => {
it('maps a label with no confidence score to a ref without a probability', () => {
const dto: AnnotationDTO[] = [
{ labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
];

const [annotation] = mapServerAnnotationsToLocal(dto);

expect(annotation.labels).toEqual([{ id: 'label-1' }]);
expect(annotation.labels[0]).not.toHaveProperty('probability');
});

it('maps a label with a confidence score to a ref with a matching probability', () => {
const dto: AnnotationDTO[] = [
{ labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.87] },
];

const [annotation] = mapServerAnnotationsToLocal(dto);

expect(annotation.labels).toEqual([{ id: 'label-1', probability: 0.87 }]);
});
});

describe('mapLocalAnnotationsToServer', () => {
it('maps confidences to null when no label has a probability', () => {
const annotation = {
id: 'annotation-1',
shape: getMockedShape({ type: 'rectangle' }),
labels: [getMockedAnnotationLabelRef({ id: 'label-1' })],
};

const [result] = mapLocalAnnotationsToServer([annotation]);

expect(result).toHaveProperty('confidences', null);
});

it('maps confidences to an array of probabilities, in label order, when present', () => {
const annotation = {
id: 'annotation-1',
shape: getMockedShape({ type: 'rectangle' }),
labels: [
getMockedAnnotationLabelRef({ id: 'label-1', probability: 0.6 }),
getMockedAnnotationLabelRef({ id: 'label-2', probability: 0.4 }),
],
};

const [result] = mapLocalAnnotationsToServer([annotation]);

expect(result.confidences).toEqual([0.6, 0.4]);
});

it('strips labels that are not in the provided valid id set', () => {
const annotation = {
id: 'annotation-1',
shape: getMockedShape({ type: 'rectangle' }),
labels: [
getMockedAnnotationLabelRef({ id: 'label-1' }),
getMockedAnnotationLabelRef({ id: 'deleted-label' }),
],
};

const [result] = mapLocalAnnotationsToServer([annotation], new Set(['label-1']));

expect(result.labels).toEqual([{ id: 'label-1' }]);
});

it('keeps all labels when no valid id set is provided', () => {
const annotation = {
id: 'annotation-1',
shape: getMockedShape({ type: 'rectangle' }),
labels: [getMockedAnnotationLabelRef({ id: 'label-1' })],
};

const [result] = mapLocalAnnotationsToServer([annotation]);

expect(result.labels).toEqual([{ id: 'label-1' }]);
});
});

describe('round trip: mapLocalAnnotationsToServer(mapServerAnnotationsToLocal(dto))', () => {
it('is deep-equal to a plain (non-prediction) server annotation, confidences included', () => {
// This is the exact shape the backend returns for a human annotation with no confidence
// scores: `confidences` is present and `null`, never omitted.
const dto: AnnotationDTO[] = [
{ labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: null },
];

const roundTripped = mapLocalAnnotationsToServer(mapServerAnnotationsToLocal(dto));

expect(roundTripped).toEqual(dto);
});

it('is deep-equal to a prediction-style server annotation, confidences included', () => {
const dto: AnnotationDTO[] = [
{ labels: [{ id: 'label-1' }], shape: getMockedShape({ type: 'rectangle' }), confidences: [0.95] },
];

const roundTripped = mapLocalAnnotationsToServer(mapServerAnnotationsToLocal(dto));

expect(roundTripped).toEqual(dto);
});
});
12 changes: 7 additions & 5 deletions application/ui/src/shared/annotator/annotation-mappers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,16 @@ export const mapLocalAnnotationsToServer = (

const hasProbabilities = filteredLabels.some((ref) => ref.probability !== undefined);

const confidences = hasProbabilities
? filteredLabels
.filter((labelRef): labelRef is Required<AnnotationLabelRef> => labelRef.probability !== undefined)
.map((labelRef) => labelRef.probability)
: null;

return {
labels: filteredLabels.map(({ id }) => ({ id })),
shape: annotation.shape,
...(hasProbabilities && {
confidences: filteredLabels
.filter((labelRef): labelRef is Required<AnnotationLabelRef> => labelRef.probability !== undefined)
.map((labelRef) => labelRef.probability),
}),
confidences,
};
});
};
Loading