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
3 changes: 2 additions & 1 deletion frontend/src/__tests__/mocks/omezarrHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,5 +48,6 @@ export const omezarrHelperMock = {
generateNeuroglancerStateForOmeZarr: vi.fn(() => 'mock-state-ome-zarr'),
determineLayerType: vi.fn(async () => 'image'),
translateUnitToNeuroglancer: vi.fn((unit: string) => unit),
getResolvedScales: vi.fn(() => [1.0, 0.5, 0.5])
getResolvedScales: vi.fn(() => [1.0, 0.5, 0.5]),
getDatasetWarnings: vi.fn(() => [])
};
153 changes: 153 additions & 0 deletions frontend/src/__tests__/unitTests/datasetWarnings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { describe, it, expect } from 'vitest';
import { getDatasetWarnings } from '@/omezarr-helper';
import type { Metadata } from '@/omezarr-helper';

// Minimal stand-in for the parts of Metadata the checks read. Codec info is
// left out by default, which the chunk check treats as compressed.
const createMetadata = (
chunks: number[],
dtype = 'uint16',
extra: Partial<Metadata> = {},
shape: number[] = [8, 8, 8]
): Metadata =>
({
arr: { chunks, dtype, shape },
...extra
}) as unknown as Metadata;

const levels = (count: number): Partial<Metadata> => ({
multiscales: [
{ datasets: Array.from({ length: count }, () => ({})) }
] as unknown as Metadata['multiscales']
});

// zstd nested inside a sharding_indexed pipeline, as a sharded v3 array stores it.
const SHARDED_ZSTD: Partial<Metadata> = {
codecs: [
{
name: 'sharding_indexed',
configuration: { codecs: [{ name: 'bytes' }, { name: 'zstd' }] }
}
]
};
const UNCOMPRESSED_V3: Partial<Metadata> = {
codecs: [{ name: 'bytes' }, { name: 'crc32c' }]
};

describe('getDatasetWarnings: chunk size', () => {
it('says nothing about reasonable chunks', () => {
expect(getDatasetWarnings(createMetadata([64, 64, 64]))).toEqual([]);
});

it('does not warn about a compressed 48 MB chunk', () => {
// 48 MB inner chunks that zstd takes to well under the 32 MB guidance.
expect(
getDatasetWarnings(
createMetadata([24, 128, 128, 128], 'uint8', SHARDED_ZSTD)
)
).toEqual([]);
});

it('holds an uncompressed array to the stricter limit', () => {
// The same 48 MB chunks, but stored raw, so 48 MB is what transfers.
for (const raw of [UNCOMPRESSED_V3, { compressor: null }]) {
expect(
getDatasetWarnings(createMetadata([24, 128, 128, 128], 'uint8', raw))
).toEqual([
{
case: 'zarr-large-chunks',
size: '48 MB',
compressed: false,
sharded: false
}
]);
}
});

it('finds a compressor nested inside a sharding codec', () => {
// sharding_indexed is structural, so a flat scan would call this
// uncompressed and warn at 48 MB.
expect(
getDatasetWarnings(
createMetadata([24, 128, 128, 128], 'uint8', SHARDED_ZSTD)
)
).toEqual([]);
});

it('assumes compressed when codec metadata was never fetched', () => {
// Unknown lands on the permissive limit: a missed warning beats a false one.
expect(
getDatasetWarnings(createMetadata([24, 128, 128, 128], 'uint8'))
).toEqual([]);
});

it('warns above the compressed limit', () => {
// seed151 img: 128 MB chunks.
expect(
getDatasetWarnings(createMetadata([256, 256, 256, 8], 'uint8'))
).toEqual([
{
case: 'zarr-large-chunks',
size: '128 MB',
compressed: true,
sharded: false
}
]);
});

it('calls out that a sharded array is measured by its inner chunks', () => {
// zarrita resolves the sharding codec, so arr.chunks is the inner chunk
// shape - the shard around it is never what we size.
expect(
getDatasetWarnings(
createMetadata([256, 256, 256, 8], 'uint8', SHARDED_ZSTD)
)
).toEqual([
{
case: 'zarr-large-chunks',
size: '128 MB',
compressed: true,
sharded: true
}
]);
});

it('accounts for the dtype width', () => {
expect(getDatasetWarnings(createMetadata([256, 256, 256]))).toEqual([]);
expect(
getDatasetWarnings(createMetadata([256, 256, 256], 'float64'))
).toHaveLength(1);
});
});

describe('getDatasetWarnings: resolution levels', () => {
const BIG = [3000, 3000, 1350, 8]; // 91 GB of uint8, the seed151 img extent

it('warns when multiscales declares a single level for a large image', () => {
expect(
getDatasetWarnings(createMetadata([64, 64, 64], 'uint8', levels(1), BIG))
).toEqual([{ case: 'zarr-single-level', size: '91 GB' }]);
});

it('says nothing when the pyramid has levels', () => {
expect(
getDatasetWarnings(createMetadata([64, 64, 64], 'uint8', levels(5), BIG))
).toEqual([]);
});

it('says nothing about a small single-level image', () => {
expect(
getDatasetWarnings(
createMetadata([64, 64, 64], 'uint8', levels(1), [256, 256, 256])
)
).toEqual([]);
});

it('never fires on a plain array, however large', () => {
// The bug that made this warn on raw/s2: a plain array also has one shape,
// but it declares no multiscales and so claims nothing.
expect(
getDatasetWarnings(createMetadata([64, 64, 64], 'uint8', {}, BIG))
).toEqual([]);
});
});
20 changes: 20 additions & 0 deletions frontend/src/components/ui/BrowsePage/MetadataHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ type MetadataHintVariant =
| { case: 'zarr-v2-no-multiscales' }
| { case: 'zarr-v3-no-multiscales' }
| { case: 'zarr-query-error'; errorMessage?: string }
// Zarr - metadata is valid, but the layout will make viewing awkward
| { case: 'zarr-single-level'; size: string }
| {
case: 'zarr-large-chunks';
size: string;
compressed: boolean;
sharded: boolean;
}
// N5 - query never fired
| { case: 'n5-has-s0-no-attrs' }
| { case: 'n5-has-attrs-no-s0' }
Expand Down Expand Up @@ -72,6 +80,18 @@ function getHintConfig(variant: MetadataHintVariant): HintConfig {
? `Could not read Zarr metadata. ${variant.errorMessage}`
: 'Could not read Zarr metadata.'
};
case 'zarr-single-level':
return {
kind: 'warning',
title: 'Only one resolution level',
description: `This dataset declares multiscales but only supplies a single level, for ${variant.size} of data. Without multiple levels, viewers read the full-resolution data at every zoom level, making viewing slow. Generating a multiscale pyramid fixes this.`
};
case 'zarr-large-chunks':
return {
kind: 'warning',
title: 'Chunks may be too large for efficient viewing',
description: `This dataset uses ${variant.size} ${variant.sharded ? 'inner chunks' : 'chunks'} ${variant.compressed ? '(before compression)' : '(without compression)'}. Very large chunks make viewing slow, because a viewer must fetch a whole chunk to show any part of it. A stored chunk size of 1-32 MB works best.`
};
case 'n5-has-s0-no-attrs':
logger.info(
'This folder has a .n5 extension but does not contain an attributes.json file required for N5 metadata preview.'
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/components/ui/BrowsePage/ZarrPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,14 @@ import zarrLogo from '@/assets/zarr.jpg';
import ZarrMetadataTable from '@/components/ui/BrowsePage/ZarrMetadataTable';
import DataLinkDialog from '@/components/ui/Dialogs/DataLink';
import DataToolLinks from './DataToolLinks';
import MetadataHint from './MetadataHint';
import type {
OpenWithToolUrls,
ZarrMetadata,
PendingToolKey
} from '@/hooks/useZarrMetadata';
import useDataToolLinks from '@/hooks/useDataToolLinks';
import { Metadata } from '@/omezarr-helper';
import { Metadata, getDatasetWarnings } from '@/omezarr-helper';

type ZarrPreviewProps = {
readonly fspName: string;
Expand Down Expand Up @@ -41,6 +42,12 @@ export default function ZarrPreview({
const [showDataLinkDialog, setShowDataLinkDialog] = useState<boolean>(false);
const [pendingToolKey, setPendingToolKey] = useState<PendingToolKey>(null);

const metadata = zarrMetadataQuery.data?.metadata;
const warnings =
metadata && 'arr' in metadata
? getDatasetWarnings(metadata as Metadata)
: [];

const {
handleToolClick,
handleDialogConfirm,
Expand All @@ -55,6 +62,13 @@ export default function ZarrPreview({

return (
<div className="min-w-full p-4 shadow-sm rounded-md bg-primary-light/30">
{warnings.length > 0 ? (
<div className="flex flex-col gap-2 mb-4">
{warnings.map(warning => (
<MetadataHint key={warning.case} variant={warning} />
))}
</div>
) : null}
<div className="flex gap-12 w-full h-fit">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2 max-h-full">
Expand Down
Loading
Loading