Skip to content
Open
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 @@ -4,18 +4,24 @@
import type { Annotation as AnnotationType } from '../types';
import { AnnotationContext } from './annotation-context';
import { AnnotationShapeWithLabels } from './annotation-shape-with-labels.component';
import { AnnotationShape } from './annotation-shape.component';
import { EditableAnnotation } from './editable-annotation.component';
import { SelectableAnnotation } from './selectable-annotation.component';

interface AnnotationProps {
annotation: AnnotationType;
withLabels?: boolean;
}
export const Annotation = ({ annotation }: AnnotationProps) => {
export const Annotation = ({ annotation, withLabels }: AnnotationProps) => {
return (
<AnnotationContext.Provider value={annotation}>
<SelectableAnnotation>
<EditableAnnotation>
<AnnotationShapeWithLabels annotation={annotation} />
{withLabels ? (
<AnnotationShapeWithLabels annotation={annotation} />
) : (
<AnnotationShape annotation={annotation} />
)}
</EditableAnnotation>
</SelectableAnnotation>
</AnnotationContext.Provider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Copyright (C) 2025 Intel Corporation
// SPDX-License-Identifier: Apache-2.0

import { MouseEvent } from 'react';
import { CSSProperties, MouseEvent } from 'react';

import { isEmpty } from 'lodash-es';
import { useAnnotationVisibility } from 'src/shared/annotator/annotation-visibility-provider.component';

import { useSelectedAnnotations } from '../../../shared/annotator/select-annotation-provider.component';
import type { Annotation as AnnotationType } from '../types';
Expand All @@ -20,6 +21,7 @@ type AnnotationsProps = {

export const Annotations = ({ annotations, width, height, isFocussed }: AnnotationsProps) => {
const { setSelectedAnnotations } = useSelectedAnnotations();
const { isVisible } = useAnnotationVisibility();

// If the user clicks on an empty spot on the canvas, we want to deselect
// all annotations
Expand All @@ -36,19 +38,25 @@ export const Annotations = ({ annotations, width, height, isFocussed }: Annotati
height={height}
tabIndex={-1}
onClick={handleBackgroundClick}
style={{
position: 'absolute',
inset: 0,
outline: 'none',
overflow: 'visible',
...DEFAULT_ANNOTATION_STYLES,
}}
style={
{
'--annotation-stroke': '1px solid var(--energy-blue)',
'--annotation-fill': 'rgba(0, 199, 253, 0.2)',
position: 'absolute',
inset: 0,
outline: 'none',
overflow: 'visible',
...DEFAULT_ANNOTATION_STYLES,
} as CSSProperties
}
>
{!isEmpty(annotations) && (
{!isEmpty(annotations) && isVisible && (
<MaskAnnotations annotations={annotations} width={width} height={height} isEnabled={isFocussed}>
{annotations.map((annotation) => (
<Annotation annotation={annotation} key={annotation.id} />
))}
<g aria-label={'annotation list'}>
{annotations.map((annotation) => (
<Annotation annotation={annotation} key={annotation.id} />
))}
</g>
</MaskAnnotations>
)}
</svg>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.image {
filter: brightness(var(--image-brightness)) saturate(var(--image-saturation)) contrast(var(--image-contrast));
image-rendering: var(--pixel-view);
}
28 changes: 21 additions & 7 deletions application/ui/src/features/annotator/annotator-canvas.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
// Copyright (C) 2025 Intel Corporation
// SPDX-License-Identifier: Apache-2.0

import { useCallback } from 'react';

import { View } from '@geti/ui';
import { useProjectIdentifier } from 'hooks/use-project-identifier.hook';
import { API_BASE_URL } from 'src/api/client';
import type { DatasetItem } from 'src/constants/shared-types';
import { useAnnotator } from 'src/shared/annotator/annotator-provider.component';

import { ZoomTransform } from '../../components/zoom/zoom-transform';
import { useAnnotationActions } from '../../shared/annotator/annotation-actions-provider.component';
Expand All @@ -13,19 +14,32 @@ import { useSelectedAnnotations } from '../../shared/annotator/select-annotation
import { Annotations } from './annotations/annotations.component';
import { ToolManager } from './tools/tool-manager.component';

const getImageUrl = (projectId: string, itemId: string) => {
return `${API_BASE_URL}/api/projects/${projectId}/dataset/items/${itemId}/binary`;
};
import styles from './annotator-canvas.module.scss';

type AnnotatorCanvasProps = {
mediaItem: DatasetItem;
};

export const AnnotatorCanvas = ({ mediaItem }: AnnotatorCanvasProps) => {
const project_id = useProjectIdentifier();
const { annotations } = useAnnotationActions();
const { selectedAnnotations } = useSelectedAnnotations();
const { isFocussed } = useAnnotationVisibility();
const { image } = useAnnotator();

const drawImageOnCanvas = useCallback(
(canvasRef: HTMLCanvasElement | null) => {
if (!canvasRef) return;

canvasRef.width = image.width;
canvasRef.height = image.height;

const ctx = canvasRef.getContext('2d');
if (ctx) {
ctx.putImageData(image, 0, 0);
}
},
[image]
);

// Order annotations by selection. Selected annotation should always be on top.
const orderedAnnotations = [
Expand All @@ -38,7 +52,7 @@ export const AnnotatorCanvas = ({ mediaItem }: AnnotatorCanvasProps) => {
return (
<ZoomTransform target={size}>
<View position={'relative'} width={'100%'} height={'100%'}>
<img src={getImageUrl(project_id, String(mediaItem.id))} alt='Collected data' />
<canvas aria-label='Captured frame' ref={drawImageOnCanvas} className={styles.image} />

<Annotations
annotations={orderedAnnotations}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright (C) 2025 Intel Corporation
// SPDX-License-Identifier: Apache-2.0

import { Content, Dialog } from '@geti/ui';
import { Label as LabelType } from 'src/constants/shared-types';

import { CreateLabelForm } from './create-label-form.component';

interface AddLabelDialogProps {
closeDialog: () => void;
existingLabels: LabelType[];
}

export const AddLabelDialog = ({ existingLabels, closeDialog }: AddLabelDialogProps) => {
return (
<Dialog>
<Content>
<CreateLabelForm onClose={closeDialog} existingLabels={existingLabels} />
</Content>
</Dialog>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Copyright (C) 2025 Intel Corporation
// SPDX-License-Identifier: Apache-2.0

import { Button, DialogTrigger } from '@geti/ui';
import { Label as LabelType } from 'src/constants/shared-types';

import { AddLabelDialog } from './add-label-dialog.component';

import classes from './add-label.module.scss';

interface AddLabelProps {
existingLabels: LabelType[];
}

export const AddLabel = ({ existingLabels }: AddLabelProps) => {
return (
<DialogTrigger type={'popover'} hideArrow placement={'bottom right'}>
<Button variant={'secondary'} UNSAFE_className={classes.addLabelButton}>
Add label
</Button>
{(close) => <AddLabelDialog closeDialog={close} existingLabels={existingLabels} />}
</DialogTrigger>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.addLabelButton {
border: none;
min-width: max-content;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Copyright (C) 2025 Intel Corporation
// SPDX-License-Identifier: Apache-2.0

import { useMemo } from 'react';

import { Flex } from '@geti/ui';
import { Add } from '@geti/ui/icons';
import { getDistinctColorBasedOnHash } from '@geti/ui/utils';
import { useProjectIdentifier } from 'hooks/use-project-identifier.hook';
import { Label as LabelType } from 'src/constants/shared-types';
import { useAnnotator } from 'src/shared/annotator/annotator-provider.component';
import { v4 as uuid } from 'uuid';

import { useUpdateLabel } from '../api/use-update-label';
import { Label } from '../label/label.component';

const getDefaultLabel = (): LabelType => {
const id = uuid();

return {
id,
name: '',
color: getDistinctColorBasedOnHash(id),
};
};

interface CreateLabelProps {
onCreateLabel: (newLabel: LabelType) => void;
onClose: () => void;
existingLabels: LabelType[];
isDisabled?: boolean;
}

const CreateLabel = ({ onCreateLabel, onClose, existingLabels, isDisabled }: CreateLabelProps) => {
const defaultLabel = useMemo(() => getDefaultLabel(), []);

return (
<Label label={defaultLabel} existingLabels={existingLabels}>
<Label.Form onSubmit={onCreateLabel}>
<Flex marginTop={0} gap={'size-50'} justifyContent={'center'}>
<Label.ColorPicker />

<Label.NameField onClose={onClose} ariaLabel={'New label name'} />

<Label.Button isDisabled={isDisabled} color={'var(--spectrum-global-color-gray-200)'}>
<Add />
</Label.Button>
</Flex>
</Label.Form>
</Label>
);
};

interface CreateLabelFormProps {
onClose: () => void;
onSuccess?: (label: LabelType) => void;
existingLabels?: LabelType[];
}

export const CreateLabelForm = ({ onClose, existingLabels = [], onSuccess }: CreateLabelFormProps) => {
const projectId = useProjectIdentifier();

const { selectedLabelId, setSelectedLabelId } = useAnnotator();

const createLabelMutation = useUpdateLabel();

const addLabel = (label: LabelType) => {
createLabelMutation.mutate(
{
body: {
labels_to_add: [
{
id: label.id,
name: label.name,
color: label.color,
},
],
},
params: {
path: {
project_id: projectId,
},
},
},
{
onSuccess: async () => {
if (selectedLabelId === null) {
setSelectedLabelId(label.id);
}

onSuccess?.(label);

onClose();
},
}
);
};

return (
<CreateLabel
existingLabels={existingLabels}
onCreateLabel={addLabel}
onClose={onClose}
isDisabled={createLabelMutation.isPending}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Copyright (C) 2025 Intel Corporation
// SPDX-License-Identifier: Apache-2.0

import { $api } from 'src/api/client';

export const useUpdateLabel = () => {
return $api.useMutation('patch', '/api/projects/{project_id}/labels');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright (C) 2025 Intel Corporation
// SPDX-License-Identifier: Apache-2.0

import { RefObject } from 'react';

import { ActionButton, Flex, View } from '@geti/ui';
import { Close } from '@geti/ui/icons';
import { ThemeProvider } from '@geti/ui/theme';
import { Popover } from 'react-aria-components';

import { Label } from '../../../constants/shared-types';
import { Point } from '../types';
import { CreateLabelForm } from './add-label/create-label-form.component';

interface CreateLabelPopoverProps {
onSuccess: (label: Label) => void;
existingLabels: Label[];
mousePosition: Point | null;
onClose: () => void;
ref: RefObject<SVGSVGElement | null>;
}

export const CreateLabelPopover = ({
onSuccess,
existingLabels,
onClose,
mousePosition,
ref,
}: CreateLabelPopoverProps) => {
if (mousePosition === null) return null;

return (
<Popover
isOpen
offset={mousePosition.y}
crossOffset={mousePosition.x}
placement={'bottom start'}
onOpenChange={onClose}
triggerRef={ref}
style={{
transform: 'translate(-50%, -50%)',
}}
>
<ThemeProvider>
<View
backgroundColor={'gray-50'}
padding={'size-200'}
height={'100%'}
borderRadius={'regular'}
borderWidth={'thin'}
borderColor={'gray-400'}
>
<Flex justifyContent={'space-between'} gap={'size-100'}>
<CreateLabelForm onClose={onClose} onSuccess={onSuccess} existingLabels={existingLabels} />
<ActionButton isQuiet onPress={onClose}>
<Close />
</ActionButton>
</Flex>
</View>
</ThemeProvider>
</Popover>
);
};
Loading
Loading