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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Add a selector for the numeric metadata histogram bin count.
- Introduce button to see expanded distribution for numeric metadata values.
- Add metadata filter chips to the left sidebar
- Display classification annotations in the annotations grid.


### Changed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
containerWidth: number;
/** Height of the grid container tile in pixels. */
containerHeight: number;
/** Whether text labels are visible globally. */
showLabel: boolean;
/** Whether this tile is currently selected. */
selected?: boolean;
/** Collection version cache-buster (same as AnnotationImageGridItem). */
Expand All @@ -26,7 +24,6 @@
annotation,
containerWidth,
containerHeight,
showLabel,
selected = false,
cachedCollectionVersion = '',
onCropWindowChange
Expand Down Expand Up @@ -73,8 +70,8 @@
aria-selected={selected}
style="width: {containerWidth}px; height: {containerHeight}px; background-image: url('{thumbnailUrl}'); background-size: cover; background-position: center;"
>
{#if showLabel}
<!-- One tile shows exactly one label — [annotation.annotation] wraps a single classification. -->
<SampleClassificationPills sample={{ annotations: [annotation.annotation] }} />
{/if}
<SampleClassificationPills
sample={{ annotations: [annotation.annotation] }}
selectedCollectionIds={[]}
Comment thread
LeonardoRosaa marked this conversation as resolved.
/>
</div>
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen } from '@testing-library/svelte';
import '@testing-library/jest-dom';
import { tick } from 'svelte';
import { type Writable } from 'svelte/store';
import {
SampleType,
AnnotationType,
Expand All @@ -12,9 +13,29 @@ import {
} from '$lib/api/lightly_studio_local';
import AnnotationClassificationGridItem from './AnnotationClassificationGridItem.svelte';

vi.mock('$lib/components/SampleClassificationPills/SampleClassificationPills.svelte', async () => {
const module = await import('./SampleClassificationPills.mock.svelte');
return { default: module.default };
const mocks = vi.hoisted(() => ({
selectedCollectionIds: null as unknown as Writable<string[]>
}));

vi.mock('$lib/hooks/useAnnotationCollectionsFilter/useAnnotationCollectionsFilter', async () => {
const { writable } = await import('svelte/store');
mocks.selectedCollectionIds = writable<string[]>([]);
return {
useAnnotationCollectionsFilter: () => ({
selectedCollectionIds: mocks.selectedCollectionIds,
collectionIdToName: writable<Record<string, string>>({})
})
};
});

vi.mock('$lib/hooks/useSettings', async () => {
const { writable } = await import('svelte/store');
return {
useSettings: vi.fn(() => ({
gridViewThumbnailQualityStore: writable('raw'),
enforceColoringByClassStore: writable(false)
}))
};
});

vi.mock('$lib/utils', async (importOriginal) => {
Expand Down Expand Up @@ -50,7 +71,6 @@ function buildAnnotation(
const defaultProps = {
containerWidth: 200,
containerHeight: 150,
showLabel: true,
cachedCollectionVersion: 'v1'
};

Expand All @@ -61,15 +81,19 @@ function renderItem(annotation: AnnotationWithPayloadView, props: Record<string,
}

describe('AnnotationClassificationGridItem', () => {
it('renders thumbnail div and SampleClassificationPills for an image annotation', async () => {
beforeEach(() => {
mocks.selectedCollectionIds.set([]);
});

it('renders thumbnail div and classification pill for an image annotation', async () => {
const { container } = renderItem(buildAnnotation());

await tick();

const thumbnail = container.firstElementChild as HTMLElement;
expect(thumbnail).toBeInTheDocument();
expect(thumbnail.style.backgroundImage).toContain('img-1');
expect(screen.getByTestId('mock-classification-pills')).toBeInTheDocument();
expect(screen.getAllByText('cat').length).toBeGreaterThan(0);
});

it('renders thumbnail using frame URL for a video frame annotation', async () => {
Expand All @@ -94,21 +118,37 @@ describe('AnnotationClassificationGridItem', () => {
expect(container.firstElementChild).toHaveAttribute('aria-selected', 'true');
});

it('hides SampleClassificationPills when showLabel is false', () => {
renderItem(buildAnnotation(), { showLabel: false });
it('always renders classification pill regardless of label visibility settings', async () => {
renderItem(buildAnnotation());

expect(screen.queryByTestId('mock-classification-pills')).not.toBeInTheDocument();
await tick();

// The badge is the primary visual indicator for classification — it shows unconditionally,
// unlike OD where the bounding box can be shown without the text label.
expect(screen.getAllByText('cat').length).toBeGreaterThan(0);
});

it('passes only the single annotation to SampleClassificationPills', async () => {
it('passes only the single annotation label to SampleClassificationPills', async () => {
renderItem(buildAnnotation());

await tick();

const pills = screen.getByTestId('mock-classification-pills');
// Exactly one annotation is passed — not all sibling labels for the parent sample.
expect(pills).toHaveAttribute('data-annotation-count', '1');
expect(pills).toHaveAttribute('data-annotation-id', 'ann-1');
// SampleClassificationPills renders two pill DOM elements (visible + hidden measurement
// overlay) when pills are present; both should carry the single label 'cat'.
expect(screen.getAllByText('cat')).toHaveLength(2);
});

it('shows classification pill even when the active source filter excludes the annotation collection', async () => {
// The hook reports a non-matching collection; without the selectedCollectionIds=[] override
// on the component, SampleClassificationPills would filter out 'col-1' and render nothing.
mocks.selectedCollectionIds.set(['other-col']);

renderItem(buildAnnotation());

await tick();

expect(screen.getAllByText('cat').length).toBeGreaterThan(0);
});

it('calls onCropWindowChange with a full-image CropWindow (windowX/Y=0) once URL is available', async () => {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { AnnotationType, type AnnotationView } from '$lib/api/lightly_studio_local';
import { buildClassificationDragData } from './AnnotationsGrid.helpers';

const annotation = {
sample_id: 'ann-1',
annotation_collection_id: 'col-1',
annotation_label: { annotation_label_name: 'cat' },
annotation_type: AnnotationType.CLASSIFICATION
} as unknown as AnnotationView;

const cropWindow = {
sourceUrl: 'blob:source',
sampleWidth: 800,
sampleHeight: 600,
windowWidth: 100,
windowHeight: 100,
windowX: 0,
windowY: 0
};

describe('buildClassificationDragData', () => {
it('returns undefined when cropWindow is undefined', () => {
const result = buildClassificationDragData({
annotation,
cropWindow: undefined,
cropUrl: undefined
});

expect(result).toBeUndefined();
});

it('uses cropUrl when provided', () => {
const result = buildClassificationDragData({
annotation,
cropWindow,
cropUrl: 'blob:crop'
});

expect(result?.url).toBe('blob:crop');
expect(result?.fileName).toBe('cat-crop.png');
expect(result).not.toHaveProperty('annotationSampleId');
expect(result).not.toHaveProperty('annotationCollectionId');
});

it('falls back to sourceUrl when cropUrl is undefined', () => {
const result = buildClassificationDragData({
annotation,
cropWindow,
cropUrl: undefined
});

expect(result?.url).toBe('blob:source');
expect(result?.fileName).toBe('cat-crop.png');
expect(result).not.toHaveProperty('annotationSampleId');
expect(result).not.toHaveProperty('annotationCollectionId');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import type { AnnotationView } from '$lib/api/lightly_studio_local';
import type { GridItemDragData } from '$lib/components/GridItem';
import type { CropWindow } from './AnnotationItem/renderCropObjectUrl';

interface AnnotationDragDataParams {
annotation: AnnotationView;
cropWindow: CropWindow | undefined;
cropUrl: string | undefined;
}

/**
* Build the drag-to-search payload for an annotation tile.
*
Expand All @@ -11,11 +17,11 @@ import type { CropWindow } from './AnnotationItem/renderCropObjectUrl';
* crop itself. The actual search uses the stored embedding looked up on drop via
* `annotationSampleId`/`annotationCollectionId`.
*/
export function buildAnnotationDragData(
annotation: AnnotationView,
cropWindow: CropWindow | undefined,
cropUrl: string | undefined
): GridItemDragData | undefined {
export function buildAnnotationDragData({
annotation,
cropWindow,
cropUrl
}: AnnotationDragDataParams): GridItemDragData | undefined {
if (!cropWindow) return undefined;
return {
url: cropUrl ?? cropWindow.sourceUrl,
Expand All @@ -24,3 +30,23 @@ export function buildAnnotationDragData(
annotationCollectionId: annotation.annotation_collection_id
};
}

/**
* Build drag-to-search payload for a classification annotation tile.
*
* Intentionally omits `annotationSampleId` — classification annotations have no
* per-annotation crop embedding, so `readAnnotationEmbedding` would fail. Omitting it
* makes the drop handler fall through to the image-upload search path (`search.setImage`),
* which sends the thumbnail as the query image.
*/
export function buildClassificationDragData({
annotation,
cropWindow,
cropUrl
}: AnnotationDragDataParams): GridItemDragData | undefined {
if (!cropWindow) return undefined;
return {
url: cropUrl ?? cropWindow.sourceUrl,
fileName: `${annotation.annotation_label.annotation_label_name}-crop.png`
};
Comment thread
LeonardoRosaa marked this conversation as resolved.
}
Loading
Loading