diff --git a/src/datasource/precomputed/frontend.ts b/src/datasource/precomputed/frontend.ts index d5ca9b1bd5..a366dda5bb 100644 --- a/src/datasource/precomputed/frontend.ts +++ b/src/datasource/precomputed/frontend.ts @@ -229,6 +229,12 @@ export interface MultiscaleVolumeInfo { modelSpace: CoordinateSpace; } +// Volume of a single voxel, used to order scales from finest to coarsest. Only +// the spatial dimensions participate; the channel resolution is always 1. +function voxelVolume(resolution: Float64Array) { + return resolution[0] * resolution[1] * resolution[2]; +} + export function parseMultiscaleVolumeInfo(obj: unknown): MultiscaleVolumeInfo { verifyObject(obj); const dataType = verifyObjectProperty(obj, "data_type", (x) => @@ -257,6 +263,16 @@ export function parseMultiscaleVolumeInfo(obj: unknown): MultiscaleVolumeInfo { parseArray(x, (y) => new ScaleInfo(y, numChannels)), ); if (scaleInfos.length === 0) throw new Error("Expected at least one scale"); + // The format requires the resolution not to decrease as the index into + // `"scales"` increases, but files that list the scales in another order do + // occur in practice. Sort them rather than trusting the order: the first + // scale defines the model coordinate space, and `getSources` must return the + // scales ordered from finest to coarsest. Sorting is a no-op for conforming + // files, since `Array.prototype.sort` is stable and the voxel volume cannot + // decrease while no individual resolution does. + scaleInfos.sort( + (a, b) => voxelVolume(a.resolution) - voxelVolume(b.resolution), + ); const baseScale = scaleInfos[0]; const rank = numChannels === 1 ? 3 : 4; const scales = new Float64Array(rank); diff --git a/testdata/datasource/precomputed/generate_precomputed.py b/testdata/datasource/precomputed/generate_precomputed.py index 24e72e36e8..68e400937c 100755 --- a/testdata/datasource/precomputed/generate_precomputed.py +++ b/testdata/datasource/precomputed/generate_precomputed.py @@ -6,6 +6,7 @@ # "numpy", # ] # /// +import json import os import shutil @@ -33,14 +34,38 @@ def write_multiscale(path: str, num_channels: int, num_scales: int): ).result() +def write_reversed_scales(source_path: str, path: str): + """Writes a copy of `source_path` with the scales listed coarsest first. + + The format requires the resolution not to decrease as the index into + `"scales"` increases, but files violating that do occur in practice, so + Neuroglancer sorts the scales rather than trusting the order. tensorstore + always writes them in order, hence this derived copy. + """ + shutil.rmtree(path, ignore_errors=True) + os.makedirs(path) + with open(os.path.join(source_path, "info")) as f: + info = json.load(f) + info["scales"] = info["scales"][::-1] + with open(os.path.join(path, "info"), "w") as f: + json.dump(info, f) + + +base = os.path.abspath(os.path.dirname(__file__)) + write_multiscale( - os.path.abspath(os.path.join(os.path.dirname(__file__), "one_channel")), + os.path.join(base, "one_channel"), num_channels=1, num_scales=3, ) write_multiscale( - os.path.abspath(os.path.join(os.path.dirname(__file__), "two_channels")), + os.path.join(base, "two_channels"), num_channels=2, num_scales=3, ) + +write_reversed_scales( + os.path.join(base, "one_channel"), + os.path.join(base, "reversed_scales"), +) diff --git a/testdata/datasource/precomputed/reversed_scales/info b/testdata/datasource/precomputed/reversed_scales/info new file mode 100644 index 0000000000..af57c6a4ca --- /dev/null +++ b/testdata/datasource/precomputed/reversed_scales/info @@ -0,0 +1 @@ +{"@type":"neuroglancer_multiscale_volume","data_type":"uint16","num_channels":1,"scales":[{"chunk_sizes":[[3,5,8]],"encoding":"raw","key":"12_16_20","resolution":[12,16,20],"size":[3,5,8],"voxel_offset":[0,0,0]},{"chunk_sizes":[[5,10,15]],"encoding":"raw","key":"6_8_10","resolution":[6,8,10],"size":[5,10,15],"voxel_offset":[0,0,0]},{"chunk_sizes":[[10,20,30]],"encoding":"raw","key":"3_4_5","resolution":[3,4,5],"size":[10,20,30],"voxel_offset":[0,0,0]}],"type":"image"} \ No newline at end of file diff --git a/tests/datasource/metadata_snapshots/precomputed/reversed_scales.snapshot b/tests/datasource/metadata_snapshots/precomputed/reversed_scales.snapshot new file mode 100644 index 0000000000..48ddd388bc --- /dev/null +++ b/tests/datasource/metadata_snapshots/precomputed/reversed_scales.snapshot @@ -0,0 +1,266 @@ +{ + "canonicalUrl": "http://localhost:*/datasource/precomputed/reversed_scales/|neuroglancer-precomputed:", + "modelTransform": { + "inputSpace": { + "bounds": { + "lowerBounds": Float64Array [ + 0, + 0, + 0, + ], + "upperBounds": Float64Array [ + 10, + 20, + 30, + ], + "voxelCenterAtIntegerCoordinates": [ + false, + false, + false, + ], + }, + "coordinateArrays": [ + , + , + , + ], + "names": [ + "x", + "y", + "z", + ], + "scales": Float64Array [ + 3e-9, + 4e-9, + 5e-9, + ], + "units": [ + "m", + "m", + "m", + ], + "valid": true, + }, + }, + "subsources": [ + { + "default": true, + "id": "default", + "subsource": { + "volume": { + "dataType": "UINT16", + "rank": 3, + "sources": [ + [ + { + "chunkSource": { + "parameters": { + "encoding": 0, + "sharding": undefined, + "url": "http://localhost:*/datasource/precomputed/reversed_scales/3_4_5/", + }, + "spec": { + "baseVoxelOffset": [ + 0, + 0, + 0, + ], + "chunkDataSize": [ + 10, + 20, + 30, + ], + "compressedSegmentationBlockSize": undefined, + "dataType": "UINT16", + "lowerVoxelBound": [ + 0, + 0, + 0, + ], + "upperVoxelBound": [ + 10, + 20, + 30, + ], + }, + }, + "lowerClipBound": Float32Array [ + 0, + 0, + 0, + ], + "upperClipBound": Float32Array [ + 10, + 20, + 30, + ], + }, + { + "chunkSource": { + "parameters": { + "encoding": 0, + "sharding": undefined, + "url": "http://localhost:*/datasource/precomputed/reversed_scales/6_8_10/", + }, + "spec": { + "baseVoxelOffset": [ + 0, + 0, + 0, + ], + "chunkDataSize": [ + 5, + 10, + 15, + ], + "compressedSegmentationBlockSize": undefined, + "dataType": "UINT16", + "lowerVoxelBound": [ + 0, + 0, + 0, + ], + "upperVoxelBound": [ + 5, + 10, + 15, + ], + }, + }, + "chunkToMultiscaleTransform": [ + [ + 2, + 0, + 0, + 0, + ], + [ + 0, + 2, + 0, + 0, + ], + [ + 0, + 0, + 2, + 0, + ], + [ + 0, + 0, + 0, + 1, + ], + ], + "lowerClipBound": Float32Array [ + 0, + 0, + 0, + ], + "upperClipBound": Float32Array [ + 5, + 10, + 15, + ], + }, + { + "chunkSource": { + "parameters": { + "encoding": 0, + "sharding": undefined, + "url": "http://localhost:*/datasource/precomputed/reversed_scales/12_16_20/", + }, + "spec": { + "baseVoxelOffset": [ + 0, + 0, + 0, + ], + "chunkDataSize": [ + 3, + 5, + 8, + ], + "compressedSegmentationBlockSize": undefined, + "dataType": "UINT16", + "lowerVoxelBound": [ + 0, + 0, + 0, + ], + "upperVoxelBound": [ + 3, + 5, + 8, + ], + }, + }, + "chunkToMultiscaleTransform": [ + [ + 4, + 0, + 0, + 0, + ], + [ + 0, + 4, + 0, + 0, + ], + [ + 0, + 0, + 4, + 0, + ], + [ + 0, + 0, + 0, + 1, + ], + ], + "lowerClipBound": Float32Array [ + 0, + 0, + 0, + ], + "upperClipBound": Float32Array [ + 2.5, + 5, + 7.5, + ], + }, + ], + ], + "volumeType": "IMAGE", + }, + }, + }, + { + "default": true, + "id": "bounds", + "subsource": { + "staticAnnotations": [ + { + "description": "Data Bounds", + "id": "data-bounds", + "pointA": [ + 0, + 0, + 0, + ], + "pointB": [ + 10, + 20, + 30, + ], + "type": "axis_aligned_bounding_box", + }, + ], + }, + }, + ], +} \ No newline at end of file diff --git a/tests/datasource/precomputed.browser_test.ts b/tests/datasource/precomputed.browser_test.ts index 735bbe16da..0922625488 100644 --- a/tests/datasource/precomputed.browser_test.ts +++ b/tests/datasource/precomputed.browser_test.ts @@ -18,4 +18,11 @@ import "#src/datasource/precomputed/register_default"; import "#src/sliceview/uncompressed_chunk_format.js"; import { datasourceMetadataSnapshotTests } from "#tests/datasource/metadata_snapshot_test_util.js"; -datasourceMetadataSnapshotTests("precomputed", ["one_channel", "two_channels"]); +datasourceMetadataSnapshotTests("precomputed", [ + "one_channel", + "two_channels", + // Same volume as `one_channel`, but with the scales listed coarsest first. + // The metadata must be identical apart from the URL, since the scales are + // sorted rather than trusted to be in order. + "reversed_scales", +]);