Skip to content

Commit bce8555

Browse files
authored
fix: detect voxel-cell convention from OME-Zarr LOD translation pattern (#664)
### Summary Multiscale pyramids may use one of two conventions for the origin of each LOD: the _corner_ or the _center_ of the first voxel. To correct for this offset, a zarr will include a half-voxel-per-LOD translation pattern ("voxel-center" convention `trans_k = trans_{k-1} + 0.5 * (scale_k - scale_{k-1})`) or with constant translations across LODs ("voxel-corner" convention). Both are valid; the choice is set by the dataset author, but not declared anywhere explicitly. Without normalization, downstream code (chunk visibility, rendering placement) gets the wrong extent for one or the other convention. We see both conventions in our sample data. This fix detects the convention per-axis in `inferSourceDimensionMap` by checking whether each LOD's translation delta matches the half-voxel prediction. For center-convention dimensions, we shift translations by `-0.5 * scale` per-axis per-LOD. After this, `chunk.offset = trans + chunkIdx * chunkSize * scale` is always the voxel-cell corner, and the rest of our code can use `[offset, offset + shape * scale]` without regard for the source data convention. See here the before/after of the "marmoset neurons" dataset. Note the offset in the corner, resolved by this PR (all corners look good after this change). The effect can be severe in 3D datasets with many levels (e.g. the exaSPIM dataset added in #662). <img width="830" height="1017" alt="Screenshot 2026-05-11 at 4 02 40 PM" src="https://github.com/user-attachments/assets/62bfd649-a951-40df-b56f-bb161906667d" /> <img width="830" height="1017" alt="Screenshot 2026-05-11 at 4 02 30 PM" src="https://github.com/user-attachments/assets/cf572110-0722-4a16-afee-b257af2caac7" /> This effect can be especially jarring in 3D data where the "fallback" chunks show a very different slice compared to the current LOD. ### Related Issue Closes #369 ### Tests & Checks Tested four datasets: zebrahub, ca wave dynamics, marmoset neurons, and exaSPIM
1 parent 06bab9c commit bce8555

1 file changed

Lines changed: 24 additions & 0 deletions

File tree

src/data/ome_zarr/image_loader.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,18 @@ function inferSourceDimensionMap(
150150
translation: translation[index],
151151
});
152152
}
153+
// Normalize translations on spatial axes so chunk.offset = trans + chunkIdx
154+
// * chunkSize * scale uniformly refers to the voxel-cell corner. Two
155+
// conventions appear in the wild: "voxel-center" pyramids encode the
156+
// half-voxel shift across LODs (translation_k - translation_{k-1} === 0.5
157+
// * (scale_k - scale_{k-1})), "voxel-corner" pyramids keep translation
158+
// constant. Detect by inspecting the pyramid; if it matches the center
159+
// pattern, shift translations by -0.5 * scale per LOD so downstream code
160+
// uses corner extents uniformly. Skip non-spatial axes (c, t) where the
161+
// convention doesn't apply and ChunkStore enforces translation == 0.
162+
if (image.axes[index].type === "space" && isVoxelCenterConvention(lods)) {
163+
for (const lod of lods) lod.translation -= 0.5 * lod.scale;
164+
}
153165
return {
154166
name,
155167
index,
@@ -199,3 +211,15 @@ function findDimensionIndex(dimensions: string[], target: string): number {
199211
function findDimensionIndexSafe(dimensions: string[], target: string): number {
200212
return dimensions.findIndex((d) => compareDimensions(d, target));
201213
}
214+
215+
function isVoxelCenterConvention(
216+
lods: ReadonlyArray<{ scale: number; translation: number }>
217+
): boolean {
218+
if (lods.length <= 1) return false;
219+
for (let i = 1; i < lods.length; i++) {
220+
const expected = 0.5 * (lods[i].scale - lods[i - 1].scale);
221+
const actual = lods[i].translation - lods[i - 1].translation;
222+
if (Math.abs(actual - expected) > 1e-6) return false;
223+
}
224+
return true;
225+
}

0 commit comments

Comments
 (0)