@jax-data-science/sci-image-visualizer 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/esm2022/index.mjs +4 -1
  2. package/esm2022/lib/contracts/image.contract.mjs +1 -1
  3. package/esm2022/lib/implementations/napari-js/napari-region-overlay.mjs +18 -6
  4. package/esm2022/lib/implementations/napari-js/napari-spatial-3d-scene.mjs +70 -2
  5. package/esm2022/lib/implementations/napari-js/napari-tool-bridge.mjs +29 -14
  6. package/esm2022/lib/implementations/napari-js/napari-visualizer.service.mjs +2 -2
  7. package/esm2022/lib/implementations/napari-js/napari-volume-region-meshes.mjs +11 -1
  8. package/esm2022/lib/implementations/napari-js/region-overlay/region-svg-renderer.mjs +9 -4
  9. package/esm2022/lib/region-editor/region-editor.component.mjs +46 -41
  10. package/esm2022/lib/region-import/label-volume.mjs +35 -4
  11. package/esm2022/lib/region-import/ome-zarr-labels.mjs +253 -0
  12. package/esm2022/lib/region-import/open-annotations.mjs +289 -0
  13. package/esm2022/lib/region-import/zarr-codecs.mjs +167 -0
  14. package/esm2022/lib/region-import/zip.mjs +157 -0
  15. package/esm2022/lib/routing-visualizer.service.mjs +20 -7
  16. package/esm2022/lib/visualizer/region-load.mjs +16 -1
  17. package/fesm2022/{jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-BELcx0jv.mjs → jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-DnPlIP2F.mjs} +1103 -71
  18. package/fesm2022/jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-DnPlIP2F.mjs.map +1 -0
  19. package/fesm2022/{jax-data-science-sci-image-visualizer-onnx-sam-session-DEDSm_NL.mjs → jax-data-science-sci-image-visualizer-onnx-sam-session-Bs987_-E.mjs} +4 -2
  20. package/fesm2022/{jax-data-science-sci-image-visualizer-onnx-sam-session-DEDSm_NL.mjs.map → jax-data-science-sci-image-visualizer-onnx-sam-session-Bs987_-E.mjs.map} +1 -1
  21. package/fesm2022/jax-data-science-sci-image-visualizer.mjs +3 -1
  22. package/fesm2022/jax-data-science-sci-image-visualizer.mjs.map +1 -1
  23. package/index.d.ts +6 -0
  24. package/lib/contracts/image.contract.d.ts +7 -0
  25. package/lib/implementations/napari-js/napari-region-overlay.d.ts +13 -1
  26. package/lib/implementations/napari-js/napari-spatial-3d-scene.d.ts +40 -1
  27. package/lib/implementations/napari-js/napari-tool-bridge.d.ts +11 -0
  28. package/lib/implementations/napari-js/napari-volume-region-meshes.d.ts +5 -0
  29. package/lib/implementations/napari-js/region-overlay/region-svg-renderer.d.ts +6 -1
  30. package/lib/region-editor/region-editor.component.d.ts +5 -4
  31. package/lib/region-import/label-volume.d.ts +4 -0
  32. package/lib/region-import/ome-zarr-labels.d.ts +36 -0
  33. package/lib/region-import/open-annotations.d.ts +73 -0
  34. package/lib/region-import/zarr-codecs.d.ts +14 -0
  35. package/lib/region-import/zip.d.ts +31 -0
  36. package/lib/routing-visualizer.service.d.ts +9 -1
  37. package/package.json +2 -1
  38. package/fesm2022/jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-BELcx0jv.mjs.map +0 -1
@@ -7,6 +7,8 @@ import { BehaviorSubject, firstValueFrom, Subject, Subscription, of, Observable,
7
7
  import { timeout, debounceTime, takeUntil, switchMap, catchError, startWith, map } from 'rxjs/operators';
8
8
  import * as ort from 'onnxruntime-web';
9
9
  import { smoothMesh, surfaceNets, pointInRing as pointInRing$2, ringArea as ringArea$1, traceContours, rasterizePolygon as rasterizePolygon$1, floodFill } from 'napari-js/geometry';
10
+ import { encode } from 'fast-png';
11
+ import { decompress } from 'fzstd';
10
12
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
11
13
  import * as i1$1 from '@angular/common';
12
14
  import { CommonModule } from '@angular/common';
@@ -5925,7 +5927,31 @@ async function readNrrd(bytes, gunzip) {
5925
5927
  // A 3D volume: sections along axis 0, each axis 2 wide and axis 1 high (jit-service's cut).
5926
5928
  const [n0, n1, n2] = sizes.length === 3 ? sizes : [sizes[0], sizes[1], 1];
5927
5929
  const axes = sizes.length === 3 ? { z: 0, x: 2, y: 1 } : { z: 2, x: 0, y: 1 };
5928
- return volume('nrrd', [n0, n1, n2], axes, data, SAMPLES[type], little);
5930
+ return volume('nrrd', [n0, n1, n2], axes, data, SAMPLES[type], little, nrrdSpacing(field, sizes.length));
5931
+ }
5932
+ /** Per file axis, the voxel size in micrometres (see {@link LabelVolume.spacing}), or undefined. */
5933
+ function nrrdSpacing(field, dims) {
5934
+ let sizes;
5935
+ const directions = field['space directions'];
5936
+ if (directions) {
5937
+ const vectors = directions.match(/\(([^)]*)\)|none/g) ?? [];
5938
+ sizes = vectors.filter((v) => v !== 'none').map((v) => Math.hypot(...v.slice(1, -1).split(',').map(Number)));
5939
+ }
5940
+ else if (field['spacings']) {
5941
+ sizes = field['spacings'].split(/\s+/).map(Number);
5942
+ }
5943
+ if (!sizes || sizes.length !== dims || sizes.some((n) => !(n > 0)))
5944
+ return undefined;
5945
+ // One unit per axis (`space units: "mm" "um" "um"`), each axis scaled by its own.
5946
+ const units = (field['space units'] ?? '').match(/"[^"]*"|\S+/g)?.map((u) => u.replace(/"/g, '').toLowerCase()) ?? [];
5947
+ const factor = (unit) => unit === 'mm' || unit === 'millimeter' || unit === 'millimeters'
5948
+ ? 1e3
5949
+ : unit === 'm' || unit === 'meter' || unit === 'meters'
5950
+ ? 1e6
5951
+ : unit === 'nm' || unit === 'nanometer' || unit === 'nanometers'
5952
+ ? 1e-3
5953
+ : 1; // micrometres, or none said
5954
+ return [...sizes.map((n, i) => n * factor(units[i])), ...(dims === 2 ? [1] : [])];
5929
5955
  }
5930
5956
  function readNifti(bytes) {
5931
5957
  if (bytes.length < 352)
@@ -5952,9 +5978,13 @@ function readNifti(bytes) {
5952
5978
  const offset = Math.max(352, Math.round(view.getFloat32(108, little)));
5953
5979
  const [n0, n1, n2] = [dims[0], dims[1], ndim >= 3 ? dims[2] : 1];
5954
5980
  // Slices along axis 2, each axis 0 wide and axis 1 high (Bio-Formats' planes).
5955
- return volume('nifti', [n0, n1, n2], { z: 2, x: 0, y: 1 }, bytes.subarray(offset), SAMPLES[type], little);
5981
+ const pixdim = [1, 2, 3].map((i) => Math.abs(view.getFloat32(76 + 4 * i, little)));
5982
+ const units = bytes[123] & 7;
5983
+ const factor = units === 1 ? 1e6 : units === 3 ? 1 : 1e3; // metres, micrometres, else millimetres
5984
+ const spacing = pixdim.every((n) => n > 0) ? pixdim.map((n) => n * factor) : undefined;
5985
+ return volume('nifti', [n0, n1, n2], { z: 2, x: 0, y: 1 }, bytes.subarray(offset), SAMPLES[type], little, spacing);
5956
5986
  }
5957
- function volume(format, n, axes, data, sample, little) {
5987
+ function volume(format, n, axes, data, sample, little, spacing) {
5958
5988
  const count = n[0] * n[1] * n[2];
5959
5989
  if (!(count > 0))
5960
5990
  throw new Error(`${format}: empty volume`);
@@ -5970,6 +6000,9 @@ function volume(format, n, axes, data, sample, little) {
5970
6000
  width,
5971
6001
  height,
5972
6002
  slices: n[axes.z],
6003
+ ...(spacing
6004
+ ? { spacing: [spacing[axes.x], spacing[axes.y], spacing[axes.z]] }
6005
+ : {}),
5973
6006
  sliceLabels(z) {
5974
6007
  const out = new Uint32Array(width * height);
5975
6008
  const base = z * stride[axes.z];
@@ -6226,6 +6259,862 @@ async function folderSliceGeoJson(file, regionSet) {
6226
6259
  return JSON.stringify(doc);
6227
6260
  }
6228
6261
 
6262
+ /**
6263
+ * The chunk codecs of the zarr stores label images come in (OME-Zarr `labels/`): Blosc framing
6264
+ * with its lz4, zstd or zlib inner codecs (zarr v2's default, as ome-zarr-py writes), zstd (zarr
6265
+ * v3's default), gzip and zlib. Anything else throws rather than be decoded wrongly. Pure: usable
6266
+ * from workers.
6267
+ *
6268
+ * The Blosc framing follows jit-bioformats' `Blosc` (the decoder JIT validated on SpatialData
6269
+ * stores): `u8 version, u8 versionlz, u8 flags, u8 typesize, u32 nbytes, u32 blocksize,
6270
+ * u32 cbytes`, then `u32 bstarts[nblocks]`, then blocks of `nstreams` sub-streams, each
6271
+ * `i32 csize` + bytes (a stream whose csize equals its decoded size stored raw, a negative one a
6272
+ * run of one byte). Flags: 0x1 byte-shuffle, 0x2 memcpyed, 0x4 bit-shuffle, 0x10 no-split, bits
6273
+ * 5-7 the inner codec.
6274
+ */
6275
+ const BLOSC_CODECS = ['blosclz', 'lz4', 'snappy', 'zlib', 'zstd'];
6276
+ /** Decode a Blosc frame. */
6277
+ async function bloscDecompress(frame, inflate = platformInflate) {
6278
+ const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
6279
+ if (frame.length < 16)
6280
+ throw new Error('blosc: frame too short');
6281
+ const flags = frame[2];
6282
+ const typesize = frame[3];
6283
+ const nbytes = view.getUint32(4, true);
6284
+ const blocksize = view.getUint32(8, true);
6285
+ const out = new Uint8Array(nbytes);
6286
+ if (nbytes === 0)
6287
+ return out;
6288
+ if (flags & 0x2) {
6289
+ out.set(frame.subarray(16, 16 + nbytes));
6290
+ return out;
6291
+ }
6292
+ if (flags & 0x4)
6293
+ throw new Error('blosc: bit-shuffle is not supported');
6294
+ const code = flags >>> 5;
6295
+ const codec = BLOSC_CODECS[code] ?? `code ${code}`;
6296
+ if (codec !== 'lz4' && codec !== 'zstd' && codec !== 'zlib') {
6297
+ throw new Error(`blosc: inner codec ${codec} is not supported (lz4, zstd or zlib)`);
6298
+ }
6299
+ const shuffle = (flags & 0x1) !== 0 && typesize > 1;
6300
+ const noSplit = (flags & 0x10) !== 0;
6301
+ const nblocks = Math.ceil(nbytes / blocksize);
6302
+ const scratch = shuffle ? new Uint8Array(blocksize) : null;
6303
+ for (let blk = 0; blk < nblocks; blk++) {
6304
+ const bsize = Math.min(blocksize, nbytes - blk * blocksize);
6305
+ const leftover = bsize !== blocksize;
6306
+ const nstreams = !noSplit && !leftover ? typesize : 1;
6307
+ const ssize = Math.floor(bsize / nstreams);
6308
+ let p = view.getUint32(16 + blk * 4, true);
6309
+ const block = scratch ?? out;
6310
+ const base = scratch ? 0 : blk * blocksize;
6311
+ for (let s = 0; s < nstreams; s++) {
6312
+ const csize = view.getInt32(p, true);
6313
+ p += 4;
6314
+ const dst = base + s * ssize;
6315
+ if (csize < 0) {
6316
+ block.fill(-csize & 0xff, dst, dst + ssize);
6317
+ continue;
6318
+ }
6319
+ const src = frame.subarray(p, p + csize);
6320
+ if (csize === ssize) {
6321
+ block.set(src, dst);
6322
+ }
6323
+ else {
6324
+ const dec = codec === 'lz4'
6325
+ ? lz4BlockDecompress(src, ssize)
6326
+ : codec === 'zstd'
6327
+ ? decompress(src)
6328
+ : await inflate(src, 'deflate');
6329
+ if (dec.length !== ssize)
6330
+ throw new Error(`blosc: ${codec} produced ${dec.length} of ${ssize} bytes`);
6331
+ block.set(dec, dst);
6332
+ }
6333
+ p += csize;
6334
+ }
6335
+ if (scratch)
6336
+ unshuffle(scratch, bsize, typesize, out, blk * blocksize);
6337
+ }
6338
+ return out;
6339
+ }
6340
+ /** Decode one LZ4 block (no frame) of `size` bytes. */
6341
+ function lz4BlockDecompress(src, size) {
6342
+ const out = new Uint8Array(size);
6343
+ let i = 0;
6344
+ let o = 0;
6345
+ while (i < src.length) {
6346
+ const token = src[i++];
6347
+ let literals = token >>> 4;
6348
+ if (literals === 15) {
6349
+ let b;
6350
+ do {
6351
+ b = src[i++];
6352
+ literals += b;
6353
+ } while (b === 255);
6354
+ }
6355
+ if (o + literals > size || i + literals > src.length)
6356
+ throw new Error('lz4: literals overrun');
6357
+ out.set(src.subarray(i, i + literals), o);
6358
+ i += literals;
6359
+ o += literals;
6360
+ if (i >= src.length)
6361
+ break; // the last sequence has no match
6362
+ const offset = src[i] | (src[i + 1] << 8);
6363
+ i += 2;
6364
+ if (offset === 0 || offset > o)
6365
+ throw new Error('lz4: bad match offset');
6366
+ let length = (token & 15) + 4;
6367
+ if ((token & 15) === 15) {
6368
+ let b;
6369
+ do {
6370
+ b = src[i++];
6371
+ length += b;
6372
+ } while (b === 255);
6373
+ }
6374
+ if (o + length > size)
6375
+ throw new Error('lz4: match overrun');
6376
+ // Byte by byte: a match may overlap what it copies (a run).
6377
+ for (let k = 0; k < length; k++, o++)
6378
+ out[o] = out[o - offset];
6379
+ }
6380
+ if (o !== size)
6381
+ throw new Error(`lz4: produced ${o} of ${size} bytes`);
6382
+ return out;
6383
+ }
6384
+ /**
6385
+ * Decode a zarr chunk through `codecs`, outermost last as zarr lists them (v2: the one
6386
+ * `compressor`; v3: the bytes-to-bytes codecs after `bytes`).
6387
+ */
6388
+ async function decodeChunk(bytes, codecs, inflate = platformInflate) {
6389
+ let data = bytes;
6390
+ for (const codec of [...codecs].reverse()) {
6391
+ switch (codec.id) {
6392
+ case 'blosc':
6393
+ data = await bloscDecompress(data, inflate);
6394
+ break;
6395
+ case 'zstd':
6396
+ data = decompress(data);
6397
+ break;
6398
+ case 'gzip':
6399
+ data = await inflate(data, 'gzip');
6400
+ break;
6401
+ case 'zlib':
6402
+ data = await inflate(data, 'deflate');
6403
+ break;
6404
+ default:
6405
+ throw new Error(`zarr: codec ${codec.id} is not supported (blosc, zstd, gzip, zlib)`);
6406
+ }
6407
+ }
6408
+ return data;
6409
+ }
6410
+ function unshuffle(src, len, typesize, dst, off) {
6411
+ const n = Math.floor(len / typesize);
6412
+ for (let j = 0; j < typesize; j++) {
6413
+ const base = j * n;
6414
+ for (let i = 0; i < n; i++)
6415
+ dst[off + i * typesize + j] = src[base + i];
6416
+ }
6417
+ // Bytes past the last whole element are not shuffled.
6418
+ for (let k = n * typesize; k < len; k++)
6419
+ dst[off + k] = src[k];
6420
+ }
6421
+ async function platformInflate(bytes, format) {
6422
+ if (typeof DecompressionStream === 'undefined')
6423
+ throw new Error(`zarr: ${format} chunks need DecompressionStream`);
6424
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream(format));
6425
+ return new Uint8Array(await new Response(stream).arrayBuffer());
6426
+ }
6427
+
6428
+ /**
6429
+ * A label image of an OME-Zarr store (`labels/<name>/`, NGFF 0.4 on zarr v2 or 0.5 on zarr v3),
6430
+ * read in the browser from the store's files (a folder chosen, or a zip's members): its full
6431
+ * resolution level, sliced along z, with the classes its `image-label` metadata names and colours.
6432
+ *
6433
+ * Chunks are decoded when a slice that needs them is read, so a store is never decoded whole.
6434
+ * Only what label images use: C-order chunks of integer (or integer-valued float) samples, through
6435
+ * the codecs {@link decodeChunk} reads, a missing chunk being the fill value. Sharded or transposed
6436
+ * arrays, F-order and filters throw rather than be read wrongly. Pure: usable from workers.
6437
+ */
6438
+ /** Whether `files` hold a zarr store (any zarr metadata among them). */
6439
+ function hasZarrMetadata(files) {
6440
+ return files.some((f) => /(^|\/)(\.zattrs|\.zarray|\.zgroup|zarr\.json)$/.test(norm(f.name)));
6441
+ }
6442
+ /**
6443
+ * The first label image of the zarr store among `files` (the order `labels/`'s listing gives,
6444
+ * else the first found), or null when the store has none.
6445
+ */
6446
+ async function readOmeZarrLabels(files, inflate) {
6447
+ const byPath = new Map(files.map((f) => [norm(f.name), f]));
6448
+ const groups = new Map();
6449
+ for (const [path, file] of byPath) {
6450
+ const m = /^(.*?)\/?(\.zattrs|zarr\.json)$/.exec(path);
6451
+ if (!m)
6452
+ continue;
6453
+ const doc = JSON.parse(await file.text());
6454
+ const attrs = (m[2] === 'zarr.json' ? doc['attributes'] : doc) ?? {};
6455
+ groups.set(m[1], (attrs['ome'] ?? attrs));
6456
+ }
6457
+ const listed = [];
6458
+ for (const [dir, ome] of groups) {
6459
+ for (const name of ome['labels'] ?? [])
6460
+ listed.push(join(dir, name));
6461
+ }
6462
+ const isLabel = (dir) => {
6463
+ const ome = groups.get(dir);
6464
+ return !!ome?.['multiscales'] && (!!ome['image-label'] || /(^|\/)labels\/[^/]+$/.test(dir));
6465
+ };
6466
+ const dir = [...listed, ...groups.keys()].find(isLabel);
6467
+ if (dir === undefined)
6468
+ return null;
6469
+ const ome = groups.get(dir);
6470
+ const ms = ome['multiscales'][0];
6471
+ const dataset = ms.datasets[0];
6472
+ const array = await readArrayMeta(byPath, join(dir, dataset.path));
6473
+ const axes = (ms.axes ?? ['z', 'y', 'x'].slice(3 - array.shape.length)).map((a) => typeof a === 'string' ? { name: a } : a);
6474
+ if (axes.length !== array.shape.length)
6475
+ throw new Error(`OME-Zarr ${dir}: ${axes.length} axes for a ${array.shape.length}-D array`);
6476
+ const at = (n) => axes.findIndex((a) => a.name.toLowerCase() === n);
6477
+ const ix = at('x');
6478
+ const iy = at('y');
6479
+ const iz = at('z');
6480
+ if (ix < 0 || iy < 0)
6481
+ throw new Error(`OME-Zarr ${dir}: no x and y axes`);
6482
+ const scale = dataset.coordinateTransformations?.find((t) => t.type === 'scale')?.scale;
6483
+ const um = (i) => (i < 0 ? 1 : (scale?.[i] ?? 1) * unitToMicrons(axes[i].unit));
6484
+ const stack = labelStack(byPath, join(dir, dataset.path), array, { ix, iy, iz }, inflate);
6485
+ stack.spacing = [um(ix), um(iy), iz < 0 ? 1 : um(iz)];
6486
+ const name = ms.name ?? dir.split('/').pop() ?? 'labels';
6487
+ return { name, stack, classes: imageLabelClasses(ome['image-label']) };
6488
+ }
6489
+ async function readArrayMeta(byPath, path) {
6490
+ const v2 = byPath.get(join(path, '.zarray'));
6491
+ if (v2) {
6492
+ const m = JSON.parse(await v2.text());
6493
+ if (m.order && m.order !== 'C')
6494
+ throw new Error(`zarr ${path}: ${m.order}-order chunks are not supported`);
6495
+ if (m.filters?.length)
6496
+ throw new Error(`zarr ${path}: filters are not supported`);
6497
+ const sep = m.dimension_separator ?? '.';
6498
+ return {
6499
+ shape: m.shape,
6500
+ chunks: m.chunks,
6501
+ dtype: v2Dtype(m.dtype, path),
6502
+ codecs: m.compressor ? [m.compressor] : [],
6503
+ fill: Number(m.fill_value ?? 0) || 0,
6504
+ key: (index) => index.join(sep),
6505
+ };
6506
+ }
6507
+ const v3 = byPath.get(join(path, 'zarr.json'));
6508
+ if (!v3)
6509
+ throw new Error(`zarr ${path}: no array metadata`);
6510
+ const m = JSON.parse(await v3.text());
6511
+ if (m.chunk_grid?.name !== 'regular')
6512
+ throw new Error(`zarr ${path}: chunk grid ${m.chunk_grid?.name} is not supported`);
6513
+ const codecs = (m.codecs ?? []);
6514
+ const bytes = codecs[0];
6515
+ if (bytes?.name !== 'bytes') {
6516
+ throw new Error(`zarr ${path}: codec ${bytes?.name ?? 'none'} before bytes is not supported (sharding, transpose)`);
6517
+ }
6518
+ const little = (bytes.configuration?.['endian'] ?? 'little') === 'little';
6519
+ const enc = m.chunk_key_encoding ?? { name: 'default' };
6520
+ const sep = enc.configuration?.separator ?? (enc.name === 'v2' ? '.' : '/');
6521
+ return {
6522
+ shape: m.shape,
6523
+ chunks: m.chunk_grid.configuration.chunk_shape,
6524
+ dtype: v3Dtype(m.data_type, little, path),
6525
+ codecs: codecs.slice(1).map((c) => ({ id: c.name, ...(c.configuration ?? {}) })),
6526
+ fill: Number(m.fill_value ?? 0) || 0,
6527
+ key: (index) => (enc.name === 'v2' ? index.join(sep) : ['c', ...index].join(sep)),
6528
+ };
6529
+ }
6530
+ /** The slices of the array at `path`, decoding (and keeping) the chunk layers a slice spans. */
6531
+ function labelStack(byPath, path, a, axes, inflate) {
6532
+ const { ix, iy, iz } = axes;
6533
+ const width = a.shape[ix];
6534
+ const height = a.shape[iy];
6535
+ const slices = iz < 0 ? 1 : a.shape[iz];
6536
+ const cx = a.chunks[ix];
6537
+ const cy = a.chunks[iy];
6538
+ // The chunks (their decodes, pending or done) of the last chunk layer along z read: a slice's
6539
+ // neighbours share it. A read keeps the layer it started with, so a read of another layer
6540
+ // replacing this cache while it awaits a chunk cannot change what it reads.
6541
+ let layer = null;
6542
+ const chunk = async (index) => {
6543
+ const file = byPath.get(join(path, a.key(index)));
6544
+ if (!file)
6545
+ return null;
6546
+ return decodeChunk(await file.bytes(), a.codecs, inflate);
6547
+ };
6548
+ return {
6549
+ width,
6550
+ height,
6551
+ slices,
6552
+ async labels(z) {
6553
+ if (z < 0 || z >= slices)
6554
+ throw new Error(`slice ${z} of ${slices}`);
6555
+ const kz = iz < 0 ? 0 : Math.floor(z / a.chunks[iz]);
6556
+ if (layer?.kz !== kz)
6557
+ layer = { kz, chunks: new Map() };
6558
+ const mine = layer;
6559
+ const out = new Uint32Array(width * height);
6560
+ const strides = a.chunks.map((_, d) => a.chunks.slice(d + 1).reduce((p, n) => p * n, 1));
6561
+ for (let gy = 0; gy * cy < height; gy++) {
6562
+ for (let gx = 0; gx * cx < width; gx++) {
6563
+ // Every other axis (time, channel) at its first index.
6564
+ const index = a.shape.map((_, d) => (d === ix ? gx : d === iy ? gy : d === iz ? kz : 0));
6565
+ const key = index.join(',');
6566
+ let pending = mine.chunks.get(key);
6567
+ if (!pending) {
6568
+ pending = chunk(index);
6569
+ mine.chunks.set(key, pending);
6570
+ // A chunk that failed to decode is not kept: the next read tries it again.
6571
+ pending.catch(() => mine.chunks.delete(key));
6572
+ }
6573
+ const data = await pending;
6574
+ const view = data ? new DataView(data.buffer, data.byteOffset, data.byteLength) : null;
6575
+ const inZ = iz < 0 ? 0 : z - kz * a.chunks[iz];
6576
+ for (let y = gy * cy; y < Math.min(height, (gy + 1) * cy); y++) {
6577
+ for (let x = gx * cx; x < Math.min(width, (gx + 1) * cx); x++) {
6578
+ let v = a.fill;
6579
+ if (view) {
6580
+ const local = a.shape.map((_, d) => d === ix ? x - gx * cx : d === iy ? y - gy * cy : d === iz ? inZ : 0);
6581
+ const i = local.reduce((p, n, d) => p + n * strides[d], 0);
6582
+ v = a.dtype.read(view, i * a.dtype.bytes);
6583
+ }
6584
+ if (v > 0xffffffff) {
6585
+ // A label id past 32 bits would be another label once narrowed: refuse it.
6586
+ throw new Error(`zarr ${path}: label ${v} does not fit 32 bits`);
6587
+ }
6588
+ out[y * width + x] = v > 0 ? v : 0;
6589
+ }
6590
+ }
6591
+ }
6592
+ }
6593
+ return out;
6594
+ },
6595
+ };
6596
+ }
6597
+ const SAMPLE = {
6598
+ u1: { bytes: 1, read: (v, at) => v.getUint8(at) },
6599
+ i1: { bytes: 1, read: (v, at) => v.getInt8(at) },
6600
+ u2: { bytes: 2, read: (v, at, le) => v.getUint16(at, le) },
6601
+ i2: { bytes: 2, read: (v, at, le) => v.getInt16(at, le) },
6602
+ u4: { bytes: 4, read: (v, at, le) => v.getUint32(at, le) },
6603
+ i4: { bytes: 4, read: (v, at, le) => v.getInt32(at, le) },
6604
+ u8: { bytes: 8, read: (v, at, le) => Number(v.getBigUint64(at, le)) },
6605
+ i8: { bytes: 8, read: (v, at, le) => Number(v.getBigInt64(at, le)) },
6606
+ f4: { bytes: 4, read: (v, at, le) => Math.round(v.getFloat32(at, le)) },
6607
+ f8: { bytes: 8, read: (v, at, le) => Math.round(v.getFloat64(at, le)) },
6608
+ };
6609
+ function v2Dtype(dtype, path) {
6610
+ const m = /^([<>|])([uif])(\d)$/.exec(dtype);
6611
+ const s = m ? SAMPLE[m[2] + m[3]] : undefined;
6612
+ if (!m || !s)
6613
+ throw new Error(`zarr ${path}: dtype ${dtype} is not a label type`);
6614
+ const le = m[1] !== '>';
6615
+ return { bytes: s.bytes, read: (v, at) => s.read(v, at, le) };
6616
+ }
6617
+ function v3Dtype(type, little, path) {
6618
+ const m = /^(u?int|float)(8|16|32|64)$/.exec(type);
6619
+ const code = m ? (m[1] === 'uint' ? 'u' : m[1] === 'int' ? 'i' : 'f') + Number(m[2]) / 8 : '';
6620
+ const s = SAMPLE[code];
6621
+ if (!s)
6622
+ throw new Error(`zarr ${path}: data type ${type} is not a label type`);
6623
+ return { bytes: s.bytes, read: (v, at) => s.read(v, at, little) };
6624
+ }
6625
+ /** The classes `image-label` names (a property naming each value) and colours; others unnamed. */
6626
+ function imageLabelClasses(meta) {
6627
+ const named = new Map();
6628
+ for (const p of meta?.properties ?? []) {
6629
+ const name = ['name', 'class', 'label', 'acronym', 'structure', 'type']
6630
+ .map((k) => p[k])
6631
+ .find((v) => typeof v === 'string');
6632
+ named.set(p['label-value'], { ...named.get(p['label-value']), name: name });
6633
+ }
6634
+ for (const c of meta?.colors ?? []) {
6635
+ if (!c.rgba)
6636
+ continue;
6637
+ const hex = `#${c.rgba
6638
+ .slice(0, 3)
6639
+ .map((n) => Math.round(n).toString(16).padStart(2, '0'))
6640
+ .join('')}`.toUpperCase();
6641
+ named.set(c['label-value'], { ...named.get(c['label-value']), color: hex });
6642
+ }
6643
+ if (!named.size)
6644
+ return labelClasses(undefined);
6645
+ const classHierarchy = [];
6646
+ const seen = new Set();
6647
+ for (const [value, { name, color }] of named) {
6648
+ const cls = name ?? `label ${value}`;
6649
+ // One class per name: an instance segmentation names many values alike.
6650
+ if (!seen.has(cls))
6651
+ classHierarchy.push({ name: cls, ...(color ? { color } : {}), value });
6652
+ seen.add(cls);
6653
+ }
6654
+ const labels = Object.fromEntries([...named].map(([v, { name }]) => [v, name ?? `label ${v}`]));
6655
+ return labelClasses({ classHierarchy, labels });
6656
+ }
6657
+ function unitToMicrons(unit) {
6658
+ switch (unit?.toLowerCase()) {
6659
+ case 'nanometer':
6660
+ return 1e-3;
6661
+ case 'millimeter':
6662
+ return 1e3;
6663
+ case 'centimeter':
6664
+ return 1e4;
6665
+ case 'meter':
6666
+ return 1e6;
6667
+ default:
6668
+ return 1; // micrometer, or none said
6669
+ }
6670
+ }
6671
+ /** A path relative to the store, '/'-separated, without a leading './'. */
6672
+ function norm(name) {
6673
+ return name.replace(/\\/g, '/').replace(/^\.\//, '');
6674
+ }
6675
+ function join(dir, name) {
6676
+ return dir ? `${dir}/${name}` : name;
6677
+ }
6678
+
6679
+ const EOCD = 0x06054b50;
6680
+ const ZIP64_LOCATOR = 0x07064b50;
6681
+ const ZIP64_EOCD = 0x06064b50;
6682
+ const CENTRAL = 0x02014b50;
6683
+ const LOCAL = 0x04034b50;
6684
+ const U32 = 0xffffffff;
6685
+ /** Whether a file name is a zip archive. */
6686
+ function isZipName(name) {
6687
+ return /\.zip$/i.test(name);
6688
+ }
6689
+ /** The members of the archive in `bytes` (directories and macOS metadata left out). */
6690
+ function readZip(bytes, inflateRaw = platformInflateRaw) {
6691
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
6692
+ const eocd = findEocd(view);
6693
+ let count = view.getUint16(eocd + 10, true);
6694
+ let dirOffset = view.getUint32(eocd + 16, true);
6695
+ if (count === 0xffff || dirOffset === U32) {
6696
+ const locator = eocd - 20;
6697
+ if (locator < 0 || view.getUint32(locator, true) !== ZIP64_LOCATOR)
6698
+ throw new Error('zip: ZIP64 locator missing');
6699
+ const z64 = num(view, locator + 8);
6700
+ if (view.getUint32(z64, true) !== ZIP64_EOCD)
6701
+ throw new Error('zip: ZIP64 directory end missing');
6702
+ count = num(view, z64 + 32);
6703
+ dirOffset = num(view, z64 + 48);
6704
+ }
6705
+ const entries = [];
6706
+ let at = dirOffset;
6707
+ for (let i = 0; i < count; i++) {
6708
+ if (view.getUint32(at, true) !== CENTRAL)
6709
+ throw new Error('zip: bad central directory');
6710
+ const flags = view.getUint16(at + 8, true);
6711
+ const method = view.getUint16(at + 10, true);
6712
+ const crc = view.getUint32(at + 16, true);
6713
+ let compressed = view.getUint32(at + 20, true);
6714
+ let size = view.getUint32(at + 24, true);
6715
+ const nameLen = view.getUint16(at + 28, true);
6716
+ const extraLen = view.getUint16(at + 30, true);
6717
+ const commentLen = view.getUint16(at + 32, true);
6718
+ let local = view.getUint32(at + 42, true);
6719
+ const nameBytes = bytes.subarray(at + 46, at + 46 + nameLen);
6720
+ const name = (flags & 0x800 ? new TextDecoder() : new TextDecoder('latin1'))
6721
+ .decode(nameBytes)
6722
+ .replace(/\\/g, '/');
6723
+ // ZIP64: the 32-bit fields that overflowed are in the extra field, in this order.
6724
+ let x = at + 46 + nameLen;
6725
+ const extraEnd = x + extraLen;
6726
+ while (x + 4 <= extraEnd) {
6727
+ const id = view.getUint16(x, true);
6728
+ const len = view.getUint16(x + 2, true);
6729
+ if (id === 0x0001) {
6730
+ let p = x + 4;
6731
+ if (size === U32)
6732
+ ((size = num(view, p)), (p += 8));
6733
+ if (compressed === U32)
6734
+ ((compressed = num(view, p)), (p += 8));
6735
+ if (local === U32)
6736
+ local = num(view, p);
6737
+ }
6738
+ x += 4 + len;
6739
+ }
6740
+ at = extraEnd + commentLen;
6741
+ if (name.endsWith('/') || /(^|\/)__MACOSX\//.test(name) || /(^|\/)\.DS_Store$/.test(name))
6742
+ continue;
6743
+ const entry = { flags, method, crc, compressed, size, local };
6744
+ entries.push({ name, size, bytes: () => readMember(bytes, view, name, entry, inflateRaw) });
6745
+ }
6746
+ return entries;
6747
+ }
6748
+ /**
6749
+ * `files` with every `.zip` among them replaced by its members, named by their paths in it (a
6750
+ * single top folder the archive wraps everything in is kept, as a folder pick would keep it).
6751
+ */
6752
+ async function expandZips(files, inflateRaw) {
6753
+ const out = [];
6754
+ for (const file of files) {
6755
+ if (!isZipName(file.name)) {
6756
+ out.push(file);
6757
+ continue;
6758
+ }
6759
+ for (const entry of readZip(await file.bytes(), inflateRaw)) {
6760
+ out.push({
6761
+ name: entry.name,
6762
+ bytes: () => entry.bytes(),
6763
+ text: async () => new TextDecoder().decode(await entry.bytes()),
6764
+ });
6765
+ }
6766
+ }
6767
+ return out;
6768
+ }
6769
+ async function readMember(bytes, view, name, m, inflateRaw) {
6770
+ if (m.flags & 0x1)
6771
+ throw new Error(`zip: ${name} is encrypted`);
6772
+ if (view.getUint32(m.local, true) !== LOCAL)
6773
+ throw new Error(`zip: ${name}: bad local header`);
6774
+ const start = m.local + 30 + view.getUint16(m.local + 26, true) + view.getUint16(m.local + 28, true);
6775
+ const raw = bytes.subarray(start, start + m.compressed);
6776
+ if (raw.length !== m.compressed)
6777
+ throw new Error(`zip: ${name} is truncated`);
6778
+ let out;
6779
+ if (m.method === 0)
6780
+ out = raw;
6781
+ else if (m.method === 8)
6782
+ out = await inflateRaw(raw);
6783
+ else
6784
+ throw new Error(`zip: ${name} uses compression method ${m.method}, not stored or deflate`);
6785
+ if (out.length !== m.size)
6786
+ throw new Error(`zip: ${name} is ${out.length} bytes, expected ${m.size}`);
6787
+ if (crc32(out) !== m.crc)
6788
+ throw new Error(`zip: ${name} fails its CRC check`);
6789
+ return out;
6790
+ }
6791
+ /** The end-of-central-directory record: the last one, within the 64 KiB a comment may take. */
6792
+ function findEocd(view) {
6793
+ const stop = Math.max(0, view.byteLength - 22 - 0xffff);
6794
+ for (let at = view.byteLength - 22; at >= stop; at--) {
6795
+ if (view.getUint32(at, true) === EOCD)
6796
+ return at;
6797
+ }
6798
+ throw new Error('zip: not a zip archive (no central directory)');
6799
+ }
6800
+ /** A little-endian u64 that fits a double (archives under 8 PB). */
6801
+ function num(view, at) {
6802
+ return view.getUint32(at, true) + view.getUint32(at + 4, true) * 0x100000000;
6803
+ }
6804
+ let crcTable = null;
6805
+ /** CRC-32 (IEEE), as zip records it. */
6806
+ function crc32(data) {
6807
+ if (!crcTable) {
6808
+ crcTable = new Uint32Array(256);
6809
+ for (let n = 0; n < 256; n++) {
6810
+ let c = n;
6811
+ for (let k = 0; k < 8; k++)
6812
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
6813
+ crcTable[n] = c >>> 0;
6814
+ }
6815
+ }
6816
+ let c = 0xffffffff;
6817
+ for (let i = 0; i < data.length; i++)
6818
+ c = crcTable[(c ^ data[i]) & 0xff] ^ (c >>> 8);
6819
+ return (c ^ 0xffffffff) >>> 0;
6820
+ }
6821
+ async function platformInflateRaw(bytes) {
6822
+ if (typeof DecompressionStream === 'undefined')
6823
+ throw new Error('zip: deflated members need DecompressionStream');
6824
+ let inflater;
6825
+ try {
6826
+ // Compression Streams' raw DEFLATE (Chrome 103, Firefox 113, Safari 16.4 on).
6827
+ inflater = new DecompressionStream('deflate-raw');
6828
+ }
6829
+ catch {
6830
+ throw new Error('zip: this browser cannot inflate zip members (no DecompressionStream "deflate-raw")');
6831
+ }
6832
+ const stream = new Blob([bytes]).stream().pipeThrough(inflater);
6833
+ return new Uint8Array(await new Response(stream).arrayBuffer());
6834
+ }
6835
+
6836
+ /**
6837
+ * Annotations opened by themselves, with no image under them: a label volume (`.nii`, `.nii.gz`,
6838
+ * `.nrrd`, with the ontology JSON chosen with it), an OME-Zarr store's label image, a folder of
6839
+ * per-slice GeoJSON (with its `regionset.json`), one GeoJSON (z-indexed by `geometry.plane.z` or
6840
+ * not), or a `.zip` of any of these.
6841
+ *
6842
+ * {@link openAnnotations} builds the image they are shown on, which every view takes as it takes
6843
+ * any other (OpenSeadragon, napari, Plotly; a stack when there are several slices; the Volume and
6844
+ * Isosurface views and their 3D region surfaces): a label volume's slices in gray levels, its
6845
+ * regions filled over them in their classes' colours, else a blank stack the size of the regions.
6846
+ * The image carries the annotations as its own region sources, one per slice
6847
+ * ({@link IImageInfo.regionSlices}), traced or parsed the first time a slice is shown.
6848
+ * The host publishes it as it publishes any image, and disposes it when another replaces it.
6849
+ *
6850
+ * {@link readAnnotationSlices} is the part the Region Editor's import shares: the sources, by slice.
6851
+ */
6852
+ /** The longest side an image built for annotations may have: OpenSeadragon's non-tiled limit. */
6853
+ const ANNOTATION_IMAGE_MAX_DIM = 8192;
6854
+ /**
6855
+ * The annotations among `files` (zips expanded), as one region source per slice; null when there
6856
+ * are none to read. A label image's slices are traced, and a GeoJSON parsed, when the slice is
6857
+ * first shown.
6858
+ */
6859
+ async function readAnnotationSlices(chosen, decoders = {}) {
6860
+ const files = await expandZips(chosen, decoders.inflateRaw);
6861
+ const openGeoJson = decoders.openGeoJson ?? ((text) => openGeoJsonRegionSource(text));
6862
+ if (hasZarrMetadata(files)) {
6863
+ const zarr = await readOmeZarrLabels(files, decoders.inflate);
6864
+ if (!zarr)
6865
+ throw new Error('The zarr store has no label image (an OME-Zarr labels/ group).');
6866
+ return labelSlices('labels', zarr.name, zarr.stack, styled(zarr.classes, decoders), openGeoJson);
6867
+ }
6868
+ const plan = planRegionImport(files);
6869
+ if (!plan)
6870
+ return null;
6871
+ if (plan.kind === 'volume') {
6872
+ const volume = await readLabelVolume(plan.volume.name, await plan.volume.bytes(), decoders.gunzip);
6873
+ const classes = labelClasses(plan.ontology ? JSON.parse(await plan.ontology.text()) : undefined);
6874
+ return labelSlices('labels', stem(plan.volume.name), stackOf(volume), styled(classes, decoders), openGeoJson);
6875
+ }
6876
+ if (plan.kind === 'folder') {
6877
+ const regionSet = plan.regionSet ? JSON.parse(await plan.regionSet.text()) : null;
6878
+ const openers = new Map();
6879
+ plan.slices.forEach((file, z) => openers.set(z, async () => openGeoJson(await folderSliceGeoJson(file, regionSet))));
6880
+ const folder = plan.slices[0].name.split('/').slice(-2, -1)[0] || 'regions';
6881
+ return {
6882
+ kind: 'folder',
6883
+ name: folder,
6884
+ slices: plan.slices.length,
6885
+ openers,
6886
+ summary: `${plan.slices.length} slice files`,
6887
+ };
6888
+ }
6889
+ const slices = splitByPlane(await plan.file.text());
6890
+ const openers = new Map();
6891
+ slices.forEach((text, z) => openers.set(z, () => openGeoJson(text)));
6892
+ return {
6893
+ kind: 'single',
6894
+ name: stem(plan.file.name),
6895
+ slices: slices.length,
6896
+ openers,
6897
+ summary: slices.length > 1 ? `${slices.length} slices` : 'one plane',
6898
+ };
6899
+ }
6900
+ let opened = 0;
6901
+ /**
6902
+ * The image `files`' annotations are shown on by themselves (see the module comment). Throws when
6903
+ * there are none, or when their regions span more than {@link ANNOTATION_IMAGE_MAX_DIM} pixels
6904
+ * (they are then imported onto the image they were drawn on).
6905
+ */
6906
+ async function openAnnotations(files, decoders = {}) {
6907
+ const set = await readAnnotationSlices(files, { outlineLabels: true, ...decoders });
6908
+ if (!set) {
6909
+ throw new Error('Choose a label volume (.nii, .nii.gz, .nrrd, with its ontology JSON), an OME-Zarr store, ' +
6910
+ 'GeoJSON files or a folder of them, or a .zip of any of these.');
6911
+ }
6912
+ let urls = [];
6913
+ let width;
6914
+ let height;
6915
+ let openers = set.openers;
6916
+ // Releases the sources measuring opened that the store has not taken (none for a label image).
6917
+ let release = () => undefined;
6918
+ try {
6919
+ if (set.labels && set.size) {
6920
+ [width, height] = set.size;
6921
+ checkSize(width, height);
6922
+ urls = await labelSliceUrls(set.labels.stack, set.labels.classes);
6923
+ }
6924
+ else {
6925
+ // Regions alone: a blank stack as large as they reach, measured on the sources (kept open
6926
+ // for their slices, so each is parsed once).
6927
+ const measured = await measure(set.openers);
6928
+ openers = measured.openers;
6929
+ release = measured.release;
6930
+ [width, height] = measured.size;
6931
+ checkSize(width, height);
6932
+ const png = encode({ width, height, data: new Uint8Array(width * height), channels: 1, depth: 8 });
6933
+ const blank = new Blob([png], { type: 'image/png' });
6934
+ urls = Array.from({ length: set.slices }, () => URL.createObjectURL(blank));
6935
+ }
6936
+ }
6937
+ catch (err) {
6938
+ urls.forEach((u) => URL.revokeObjectURL(u));
6939
+ release();
6940
+ throw err;
6941
+ }
6942
+ const [mx, my, mz] = set.spacing ?? [];
6943
+ const isStack = set.slices > 1;
6944
+ const info = {
6945
+ isGrayscale: true,
6946
+ trueImageSize: [width, height],
6947
+ urls,
6948
+ isStack,
6949
+ showStack: false,
6950
+ scaleRatio: true,
6951
+ // Unique: the slice cache keys on the name, and two opened sets must not share slices.
6952
+ fileName: `${set.name} · annotations [${++opened}]`,
6953
+ imageMeta: [
6954
+ {
6955
+ channelCount: 1,
6956
+ rgbChannels: 1,
6957
+ x: width,
6958
+ y: height,
6959
+ z: set.slices,
6960
+ mppX: mx ?? null,
6961
+ mppY: my ?? null,
6962
+ ...(mz != null ? { mppZ: mz } : {}),
6963
+ },
6964
+ ],
6965
+ tiled: false,
6966
+ // Open mid-stack: an atlas's first and last sections are mostly empty.
6967
+ initialZIndex: isStack ? set.slices >> 1 : undefined,
6968
+ regionSlices: openers,
6969
+ };
6970
+ return {
6971
+ info,
6972
+ summary: `${set.summary}, ${width} × ${height}`,
6973
+ dispose: () => {
6974
+ urls.forEach((u) => URL.revokeObjectURL(u));
6975
+ release();
6976
+ },
6977
+ };
6978
+ }
6979
+ function labelSlices(kind, name, stack, classes, openGeoJson) {
6980
+ const openers = new Map();
6981
+ for (let z = 0; z < stack.slices; z++) {
6982
+ openers.set(z, async () => openGeoJson(labelSliceGeoJson(oneSlice(stack, await stack.labels(z)), 0, classes)));
6983
+ }
6984
+ return {
6985
+ kind,
6986
+ name,
6987
+ slices: stack.slices,
6988
+ openers,
6989
+ labels: { stack, classes },
6990
+ size: [stack.width, stack.height],
6991
+ ...(stack.spacing ? { spacing: stack.spacing } : {}),
6992
+ summary: `${stack.slices} slice${stack.slices === 1 ? '' : 's'} of ${stack.width} × ${stack.height}`,
6993
+ };
6994
+ }
6995
+ /** `classes`, with the atlas look when asked for and they name no style of their own. */
6996
+ function styled(classes, decoders) {
6997
+ return decoders.outlineLabels && !classes.style ? { ...classes, style: ATLAS_STYLE } : classes;
6998
+ }
6999
+ /** A label volume as a stack read on demand. */
7000
+ function stackOf(volume) {
7001
+ return {
7002
+ width: volume.width,
7003
+ height: volume.height,
7004
+ slices: volume.slices,
7005
+ ...(volume.spacing ? { spacing: volume.spacing } : {}),
7006
+ labels: async (z) => volume.sliceLabels(z),
7007
+ };
7008
+ }
7009
+ /** One slice's labels as the one-slice volume the tracer reads. */
7010
+ function oneSlice(stack, labels) {
7011
+ return { format: 'nrrd', width: stack.width, height: stack.height, slices: 1, sliceLabels: () => labels };
7012
+ }
7013
+ /**
7014
+ * Every slice as a grayscale PNG blob URL, each label at its class colour's luminance (never 0),
7015
+ * background black. Grayscale, not colour: the Volume and Isosurface views take a grayscale stack,
7016
+ * and the colour is the regions' own, filled over it in their classes' colours (the atlas look).
7017
+ */
7018
+ async function labelSliceUrls(stack, classes) {
7019
+ const levels = new Map();
7020
+ const levelOf = (v) => {
7021
+ let l = levels.get(v);
7022
+ if (l === undefined) {
7023
+ const hex = classes.classOf(v).color;
7024
+ const [r, g, b] = [1, 3, 5].map((o) => parseInt(hex.slice(o, o + 2), 16));
7025
+ l = Math.max(1, Math.round(0.299 * r + 0.587 * g + 0.114 * b));
7026
+ levels.set(v, l);
7027
+ }
7028
+ return l;
7029
+ };
7030
+ const urls = [];
7031
+ const gray = new Uint8Array(stack.width * stack.height);
7032
+ for (let z = 0; z < stack.slices; z++) {
7033
+ const labels = await stack.labels(z);
7034
+ for (let i = 0; i < labels.length; i++)
7035
+ gray[i] = labels[i] ? levelOf(labels[i]) : 0;
7036
+ const png = encode({ width: stack.width, height: stack.height, data: gray, channels: 1, depth: 8 });
7037
+ urls.push(URL.createObjectURL(new Blob([png], { type: 'image/png' })));
7038
+ }
7039
+ return urls;
7040
+ }
7041
+ /**
7042
+ * Open every slice's source to learn how far the regions reach (the image is as large, from the
7043
+ * origin, as region coordinates are image pixels). Each opener then hands its already-open source
7044
+ * over the first time (the region store owns it from then on) and opens anew after. `release`
7045
+ * disposes the sources not handed over yet; they are released here when measuring fails.
7046
+ */
7047
+ async function measure(openers) {
7048
+ // Sources opened here and not yet handed to the store: theirs to release until then.
7049
+ const held = new Map();
7050
+ const release = () => {
7051
+ for (const source of held.values())
7052
+ source.dispose();
7053
+ held.clear();
7054
+ };
7055
+ let x1 = 0;
7056
+ let y1 = 0;
7057
+ try {
7058
+ for (const [z, open] of openers) {
7059
+ const source = await open();
7060
+ held.set(z, source);
7061
+ const bounds = source.info.bounds;
7062
+ if (bounds) {
7063
+ x1 = Math.max(x1, bounds[2]);
7064
+ y1 = Math.max(y1, bounds[3]);
7065
+ }
7066
+ }
7067
+ if (!(x1 > 0 && y1 > 0))
7068
+ throw new Error('The regions have no extent to show.');
7069
+ }
7070
+ catch (err) {
7071
+ release();
7072
+ throw err;
7073
+ }
7074
+ const reused = new Map();
7075
+ for (const [z, open] of openers) {
7076
+ reused.set(z, () => {
7077
+ const first = held.get(z);
7078
+ if (!first)
7079
+ return open();
7080
+ held.delete(z);
7081
+ return Promise.resolve(first);
7082
+ });
7083
+ }
7084
+ return { openers: reused, size: [Math.ceil(x1), Math.ceil(y1)], release };
7085
+ }
7086
+ function checkSize(width, height) {
7087
+ if (Math.max(width, height) > ANNOTATION_IMAGE_MAX_DIM) {
7088
+ throw new Error(`The annotations span ${width} × ${height} pixels, more than ${ANNOTATION_IMAGE_MAX_DIM} on a side: ` +
7089
+ 'open the image they were drawn on, and import them onto it from the Region Editor.');
7090
+ }
7091
+ }
7092
+ /**
7093
+ * One GeoJSON's slices: a collection whose features name planes (`geometry.plane.z`) split into
7094
+ * one collection per plane, each keeping the set's class tree and style, its features moved to
7095
+ * plane 0 (a slice's source shows plane 0); any other GeoJSON is one slice as it is.
7096
+ */
7097
+ function splitByPlane(text) {
7098
+ const doc = JSON.parse(text);
7099
+ const features = doc.type === 'FeatureCollection' ? (doc.features ?? []) : [];
7100
+ const zOf = (f) => Math.max(0, Math.floor(f.geometry?.plane?.z ?? 0));
7101
+ const last = features.reduce((m, f) => Math.max(m, zOf(f)), 0);
7102
+ if (last === 0)
7103
+ return [text];
7104
+ const slices = Array.from({ length: last + 1 }, () => []);
7105
+ for (const f of features) {
7106
+ const z = zOf(f);
7107
+ const geometry = f.geometry ? { ...f.geometry } : f.geometry;
7108
+ if (geometry)
7109
+ delete geometry.plane;
7110
+ slices[z].push({ ...f, geometry });
7111
+ }
7112
+ return slices.map((list) => JSON.stringify({ ...doc, features: list }));
7113
+ }
7114
+ function stem(name) {
7115
+ return (name.split('/').pop() ?? name).replace(/\.(nii\.gz|nii|nrrd|geojson|json|zip)$/i, '');
7116
+ }
7117
+
6229
7118
  /**
6230
7119
  * The largest bbox (in pixels) {@link rasterizePolygon} allocates before it
6231
7120
  * falls back to clipping at the window. Also the cap the wand/brush use to
@@ -9510,7 +10399,7 @@ function frameToRgba(cached, frameIndex) {
9510
10399
  /** Lazy-import so onnxruntime-web is never pulled into unit tests or the
9511
10400
  * initial bundle — only when segmentation actually runs. */
9512
10401
  const onnxSessionFactory = async () => {
9513
- const { OnnxSamSession } = await import('./jax-data-science-sci-image-visualizer-onnx-sam-session-DEDSm_NL.mjs');
10402
+ const { OnnxSamSession } = await import('./jax-data-science-sci-image-visualizer-onnx-sam-session-Bs987_-E.mjs');
9514
10403
  return new OnnxSamSession();
9515
10404
  };
9516
10405
  /** Thrown inside a load that a model switch superseded. */
@@ -10449,6 +11338,21 @@ function applyImageRois(info, target, z, minSourceChars = REGION_SOURCE_MIN_CHAR
10449
11338
  const load = (loads.get(target) ?? 0) + 1;
10450
11339
  loads.set(target, load);
10451
11340
  const asSource = (json) => !!attach && !!json && json.length >= minSourceChars;
11341
+ if (info.regionSlices && attach) {
11342
+ // An image built from annotations: its regions are its own sources, one per slice.
11343
+ if (info.isStack) {
11344
+ target.enterStackMode(new Map(), z, 'per-slice-file');
11345
+ for (const [s, open] of info.regionSlices)
11346
+ attach(open, { slice: s });
11347
+ return;
11348
+ }
11349
+ target.setRegions([]);
11350
+ target.resetUndoHistory();
11351
+ const first = info.regionSlices.get(0) ?? info.regionSlices.values().next().value;
11352
+ if (first)
11353
+ attach(first, { slice: 0 });
11354
+ return;
11355
+ }
10452
11356
  if (info.isStack && info.tiled === false) {
10453
11357
  const perSlice = info.roiJsonStrs;
10454
11358
  const sliceCount = info.urls?.length ?? perSlice?.length ?? 0;
@@ -15908,53 +16812,47 @@ class RegionEditorComponent {
15908
16812
  });
15909
16813
  }
15910
16814
  /**
15911
- * Import `files` (see planRegionImport): one GeoJSON file as before; a label volume as one
15912
- * region source per slice, traced from it the first time the slice is shown, its classes named
15913
- * (and treed) by the ontology chosen with it; a folder as one source per slice file. Either
15914
- * replaces the regions and sources of every slice.
16815
+ * Import `files`: one GeoJSON file as before (its regions, editable); anything else (see
16816
+ * readAnnotationSlices) as one region source per slice, replacing the regions and sources of
16817
+ * every slice: a label volume or an OME-Zarr label image traced the first time a slice is shown,
16818
+ * its classes named (and treed) by its ontology; a folder as one source per slice file; a
16819
+ * z-indexed GeoJSON one per plane; a zip of any of these.
15915
16820
  */
15916
16821
  async importRegionFiles(files) {
15917
- const plan = planRegionImport(files);
15918
- if (!plan) {
15919
- this.importFailed(new Error('Choose a GeoJSON file, a folder of GeoJSON files, or a .nii, .nii.gz or .nrrd label volume.'));
15920
- return;
15921
- }
15922
- if (plan.kind === 'single') {
15923
- await this.applyImportedRois(await plan.file.text());
15924
- return;
15925
- }
15926
- const attach = this.regionApi.importSliceRegionSources?.bind(this.regionApi);
15927
- if (!attach) {
15928
- this.importFailed(new Error('This viewer cannot hold region sources.'));
15929
- return;
15930
- }
15931
- // Taken before the files are read: decoding a volume takes a while, and the import must land
15932
- // on the image it was started for, not on one shown meanwhile.
15933
- const ticket = this.regionApi.regionImportTicket?.();
16822
+ // Every step, reading the files included, reports its failure here rather than rejecting:
16823
+ // the inputs call this without waiting on it.
15934
16824
  try {
15935
- const openers = new Map();
15936
- let summary;
15937
- if (plan.kind === 'volume') {
15938
- const volume = await readLabelVolume(plan.volume.name, await plan.volume.bytes());
15939
- const classes = labelClasses(plan.ontology ? JSON.parse(await plan.ontology.text()) : undefined);
15940
- for (let z = 0; z < volume.slices; z++) {
15941
- openers.set(z, () => openGeoJsonRegionSource(labelSliceGeoJson(volume, z, classes)));
16825
+ const plan = files.some((f) => isZipName(f.name)) ? null : planRegionImport(files);
16826
+ if (plan?.kind === 'single') {
16827
+ const text = await plan.file.text();
16828
+ if (onePlane(text)) {
16829
+ await this.applyImportedRois(text);
16830
+ return;
15942
16831
  }
15943
- summary = `${volume.slices} slices of ${volume.width} × ${volume.height}`;
15944
- this.warnIfSizeDiffers(volume.width, volume.height);
15945
16832
  }
15946
- else {
15947
- const regionSet = plan.regionSet ? JSON.parse(await plan.regionSet.text()) : null;
15948
- plan.slices.forEach((file, z) => openers.set(z, async () => openGeoJsonRegionSource(await folderSliceGeoJson(file, regionSet))));
15949
- summary = `${plan.slices.length} slice files`;
16833
+ const attach = this.regionApi.importSliceRegionSources?.bind(this.regionApi);
16834
+ if (!attach) {
16835
+ this.importFailed(new Error('This viewer cannot hold region sources.'));
16836
+ return;
16837
+ }
16838
+ // Taken before the files are read: decoding a volume takes a while, and the import must land
16839
+ // on the image it was started for, not on one shown meanwhile.
16840
+ const ticket = this.regionApi.regionImportTicket?.();
16841
+ const set = await readAnnotationSlices(files);
16842
+ if (!set) {
16843
+ this.importFailed(new Error('Choose a GeoJSON file, a folder of GeoJSON files, a .nii, .nii.gz or .nrrd label volume, ' +
16844
+ 'an OME-Zarr store, or a .zip of any of these.'));
16845
+ return;
15950
16846
  }
15951
- const attached = attach(openers, ticket);
16847
+ if (set.size)
16848
+ this.warnIfSizeDiffers(set.size[0], set.size[1]);
16849
+ const attached = attach(set.openers, ticket);
15952
16850
  if (attached === null) {
15953
16851
  this.importStale();
15954
16852
  return;
15955
16853
  }
15956
16854
  this.afterSourceImport();
15957
- this.toast('success', 'Regions imported', `${summary}; ${attached} slice${attached === 1 ? '' : 's'} annotated.`);
16855
+ this.toast('success', 'Regions imported', `${set.summary}; ${attached} slice${attached === 1 ? '' : 's'} annotated.`);
15958
16856
  }
15959
16857
  catch (err) {
15960
16858
  this.importFailed(err);
@@ -16289,6 +17187,18 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImpor
16289
17187
  }], active: [{
16290
17188
  type: Input
16291
17189
  }] } });
17190
+ /**
17191
+ * Whether one GeoJSON file holds a single plane (its regions imported as they always were). One
17192
+ * that does not parse is taken as one too, so the regions import reports it as it always has.
17193
+ */
17194
+ function onePlane(text) {
17195
+ try {
17196
+ return splitByPlane(text).length === 1;
17197
+ }
17198
+ catch {
17199
+ return true;
17200
+ }
17201
+ }
16292
17202
 
16293
17203
  /**
16294
17204
  * Shared intensity helpers (refactoring plan, Step 5). The two backends use
@@ -38325,16 +39235,21 @@ const HANDLE_SIZE = 7;
38325
39235
  * the draft being drawn and the rubber-band marquee.
38326
39236
  */
38327
39237
  class NapariRegionSvgRenderer {
38328
- constructor(svg, store) {
39238
+ /**
39239
+ * @param draws which regions are this overlay's to draw (default: every one); the others are
39240
+ * left out of the shapes and handles, keeping the store's indices
39241
+ */
39242
+ constructor(svg, store, draws = () => true) {
38329
39243
  this.svg = svg;
38330
39244
  this.store = store;
39245
+ this.draws = draws;
38331
39246
  /** Dedicated marquee rect element, updated directly during the drag (no full region redraw). */
38332
39247
  this.marqueeEl = null;
38333
39248
  this.renderer = new SvgRegionRenderer(svg, {
38334
39249
  rectElement: 'rect',
38335
39250
  styleShape: (el, region, selected) => this.style(el, this.strokeOf(region), selected),
38336
39251
  decorate: (region, _i, _selected, layer) => this.drawLabel(region, layer),
38337
- skip: (region) => !!region.isProfile?.() || store.isClassHidden(region.label),
39252
+ skip: (region) => !!region.isProfile?.() || store.isClassHidden(region.label) || !draws(region),
38338
39253
  });
38339
39254
  }
38340
39255
  /** Follow the camera: one transform attribute plus the screen-space elements. */
@@ -38350,7 +39265,7 @@ class NapariRegionSvgRenderer {
38350
39265
  this.renderer.renderOverlay((layer) => {
38351
39266
  for (const i of selected) {
38352
39267
  const region = regions[i];
38353
- if (region && !region.isProfile?.())
39268
+ if (region && !region.isProfile?.() && this.draws(region))
38354
39269
  this.drawHandles(region, layer);
38355
39270
  }
38356
39271
  this.drawDraft(draft, layer);
@@ -38892,6 +39807,7 @@ class NapariRegionOverlay {
38892
39807
  this.commitPolygon(pts, true);
38893
39808
  this.redraw();
38894
39809
  };
39810
+ this.own = opts.screenSpace ? new Set() : null;
38895
39811
  if (getComputedStyle(host).position === 'static')
38896
39812
  host.style.position = 'relative';
38897
39813
  this.svg = document.createElementNS(SVG_NS, 'svg');
@@ -38905,7 +39821,7 @@ class NapariRegionOverlay {
38905
39821
  touchAction: 'none',
38906
39822
  });
38907
39823
  this.host.appendChild(this.svg);
38908
- this.renderer = new NapariRegionSvgRenderer(this.svg, this.store);
39824
+ this.renderer = new NapariRegionSvgRenderer(this.svg, this.store, (r) => this.draws(r));
38909
39825
  this.projection = new OverlayProjection(this.svg, this.viewer);
38910
39826
  this.sourceLayer =
38911
39827
  opts.regionSource === false ? null : new RegionSourceLayer(this.svg, this.store.getRegionSourceView$());
@@ -39038,11 +39954,20 @@ class NapariRegionOverlay {
39038
39954
  this.draftPath.push([ix, iy]);
39039
39955
  }
39040
39956
  }
39041
- /** The currently-selected region (single selection), or null. */
39957
+ /** The currently-selected region (single selection) this overlay draws, or null. */
39042
39958
  selectedRegion() {
39043
39959
  const regions = this.store.getRegions();
39044
39960
  const i = this.selected[0];
39045
- return i != null && i >= 0 && i < regions.length ? regions[i] : null;
39961
+ const region = i != null && i >= 0 && i < regions.length ? regions[i] : null;
39962
+ return region && this.draws(region) ? region : null;
39963
+ }
39964
+ /** Whether this overlay draws (and lets the pointer pick) `region`: see {@link own}. */
39965
+ draws(region) {
39966
+ return !this.own || this.own.has(region.id);
39967
+ }
39968
+ /** The regions this overlay drew in screen space (the 3D view's lassos); [] in image space. */
39969
+ ownRegionIds() {
39970
+ return this.own ? [...this.own] : [];
39046
39971
  }
39047
39972
  /**
39048
39973
  * Start a select/move interaction: grab a handle (rectangle corner) or vertex of the already-
@@ -39065,7 +39990,7 @@ class NapariRegionOverlay {
39065
39990
  const regions = this.store.getRegions();
39066
39991
  const [wx, wy] = this.projection.toWorld(e.clientX, e.clientY);
39067
39992
  for (let i = regions.length - 1; i >= 0; i--) {
39068
- if (this.store.isClassHidden(regions[i].label))
39993
+ if (this.store.isClassHidden(regions[i].label) || !this.draws(regions[i]))
39069
39994
  continue;
39070
39995
  if (regionContains(regions[i], wx, wy, { toScreen: this.projection.toScreen, tolPx: OPEN_PATH_HIT_PX })) {
39071
39996
  this.store.selectRegion(regions[i]);
@@ -39146,7 +40071,7 @@ class NapariRegionOverlay {
39146
40071
  const regions = this.store.getRegions();
39147
40072
  this.store.setSelectedShapeIndices(regionsInRect(regions, x0, y0, x1, y1, {
39148
40073
  skipProfiles: true,
39149
- skip: (r) => this.store.isClassHidden(r.label),
40074
+ skip: (r) => this.store.isClassHidden(r.label) || !this.draws(r),
39150
40075
  }));
39151
40076
  const features = this.sourceLayer?.featuresInRect(x0, y0, x1, y1) ?? [];
39152
40077
  if (features.length) {
@@ -39164,6 +40089,7 @@ class NapariRegionOverlay {
39164
40089
  region.bounds = rect;
39165
40090
  region.color = this.store.getShapeColor();
39166
40091
  this.store.addRegion(region);
40092
+ this.own?.add(region.id);
39167
40093
  }
39168
40094
  commitPolygon(points, closed) {
39169
40095
  const poly = new Polygon();
@@ -39177,6 +40103,7 @@ class NapariRegionOverlay {
39177
40103
  region.color = this.store.getShapeColor();
39178
40104
  region.label = 'Region';
39179
40105
  this.store.addRegion(region);
40106
+ this.own?.add(region.id);
39180
40107
  }
39181
40108
  // ── rendering ─────────────────────────────────────────────────────────────
39182
40109
  /** Show or hide the drawn regions. A region being drawn stays visible either way. */
@@ -39318,27 +40245,38 @@ class NapariToolBridge {
39318
40245
  * A screen-space shape stops meaning anything the moment the camera moves, so an orbit clears
39319
40246
  * the regions drawn in this view (and only those). The SELECTION they produced is kept: that is
39320
40247
  * the durable artefact, and the highlighted points stay highlighted from every angle.
40248
+ *
40249
+ * Every other region in the store is in image space - the image's own, an imported set,
40250
+ * structures a tree click took from a region source - so the overlay neither draws nor picks it
40251
+ * (it would lie flat at canvas pixels over the cloud), and an orbit leaves it alone: they show
40252
+ * in 3D as their reconstructions instead.
39321
40253
  */
39322
40254
  install3dInteraction(viewer, host) {
39323
40255
  const { regionStore } = this.deps;
39324
40256
  // World here is the canvas, not the image: no region source to draw.
39325
- this.regionOverlay = new NapariRegionOverlay(host, this.screenSpaceViewer(viewer), regionStore, {
40257
+ const overlay = new NapariRegionOverlay(host, this.screenSpaceViewer(viewer), regionStore, {
39326
40258
  regionSource: false,
40259
+ screenSpace: true,
39327
40260
  });
40261
+ this.regionOverlay = overlay;
39328
40262
  this.buildCoordinateTransform();
39329
- // Regions already in the shared store when the 3D view mounted (the image's own, say) are
39330
- // not screen-space shapes and must survive an orbit; only those drawn here are cleared.
39331
40263
  // (No mid-gesture case to guard: the overlay disables the camera controls while drawing.)
39332
- const foreign = new Set(regionStore.getRegions().map((r) => r.id));
39333
- this.cameraOff = viewer.camera3d.changed.connect(() => {
39334
- const drawn = regionStore
39335
- .getRegions()
39336
- .filter((r) => !foreign.has(r.id))
39337
- .map((r) => r.id);
39338
- // Not an edit of the user's: no undo step (NAPARI-SVC-11).
39339
- if (drawn.length)
39340
- this.host.inZone(() => regionStore.removeRegions(drawn, { recordUndo: false }));
39341
- });
40264
+ this.cameraOff = viewer.camera3d.changed.connect(() => this.clearOwnLassos());
40265
+ }
40266
+ /**
40267
+ * Remove the regions the 3D overlay drew in screen space (only those) from the store: a lasso
40268
+ * means nothing once the camera it was drawn under moves or the scene goes. Not an edit of the
40269
+ * user's: no undo step (NAPARI-SVC-11).
40270
+ */
40271
+ clearOwnLassos() {
40272
+ const overlay = this.regionOverlay;
40273
+ if (!overlay)
40274
+ return;
40275
+ const { regionStore } = this.deps;
40276
+ const present = new Set(regionStore.getRegions().map((r) => r.id));
40277
+ const drawn = overlay.ownRegionIds().filter((id) => present.has(id));
40278
+ if (drawn.length)
40279
+ this.host.inZone(() => regionStore.removeRegions(drawn, { recordUndo: false }));
39342
40280
  }
39343
40281
  /**
39344
40282
  * The {@link OverlayViewer} the 3D region overlay draws through: "world" is the canvas, in
@@ -39360,6 +40298,10 @@ class NapariToolBridge {
39360
40298
  /** End the scene's interaction: the overlay, the camera hook, the pending timers and the
39361
40299
  * pixels read from the old canvas. The armed tool and the pixel-tool set persist. */
39362
40300
  teardown() {
40301
+ // The overlay's record of its lassos goes with it: remove them first, or they would stay in
40302
+ // the store unowned - hidden by the next 3D overlay, beyond an orbit's reach, and still
40303
+ // feeding "Select from ROIs" with screen coordinates of a camera that is gone.
40304
+ this.clearOwnLassos();
39363
40305
  this.regionOverlay?.destroy();
39364
40306
  this.regionOverlay = null;
39365
40307
  this.cachedImage = null;
@@ -40407,6 +41349,16 @@ class VolumeRegionMeshes {
40407
41349
  this.remove(key, e.layer);
40408
41350
  this.wanted = [];
40409
41351
  }
41352
+ /**
41353
+ * The volume the surfaces sat in is gone (or no longer theirs): take every surface down, keeping
41354
+ * the meshes wanted, so they come back when a frame is set again.
41355
+ */
41356
+ clearFrame() {
41357
+ this.frame = null;
41358
+ for (const [key, e] of [...this.layers])
41359
+ this.remove(key, e.layer);
41360
+ this.viewer.requestRender();
41361
+ }
40410
41362
  sync() {
40411
41363
  const keep = new Map(this.wanted.map((m) => [m.key, m]));
40412
41364
  for (const [key, e] of [...this.layers]) {
@@ -44725,8 +45677,13 @@ function slabAt(dataset, slice) {
44725
45677
  * shape while keeping the selection. The first 3D layer added frames the camera (`fit3d: 'once'`).
44726
45678
  */
44727
45679
  class Spatial3dScene extends SpatialSceneBase {
44728
- constructor(ctx, session) {
45680
+ /**
45681
+ * @param info the image plotted with the cloud: the reference volume's stack (the 2D view's
45682
+ * image, see showVolumeAsImage) when the dataset has a volume, which the regions are on
45683
+ */
45684
+ constructor(ctx, session, info) {
44729
45685
  super(ctx, session, true);
45686
+ this.info = info;
44730
45687
  /** The 3D point cloud. One layer: selection is a per-point alpha on it, not a second. */
44731
45688
  this.points = null;
44732
45689
  this.layerKey = null;
@@ -44768,9 +45725,64 @@ class Spatial3dScene extends SpatialSceneBase {
44768
45725
  * one; a rebuild that leaves the key alone lets it finish. */
44769
45726
  this.densityLoads = new Supersede();
44770
45727
  this.geneMapLoads = new Supersede();
45728
+ /** Whether the reference volume lets every region surface through (see setRegionsThroughVolume). */
45729
+ this.regionsThrough = false;
44771
45730
  /** The store's invert setting, which the density volumes follow (no other subscription of
44772
45731
  * this scene carries it). */
44773
45732
  this.invertSub = null;
45733
+ this.regionMeshes = new VolumeRegionMeshes(ctx.viewer);
45734
+ }
45735
+ /** Show these region reconstructions with the cloud (replacing the ones shown). */
45736
+ setRegionMeshes(meshes) {
45737
+ this.regionMeshes.set(meshes);
45738
+ }
45739
+ setRegionHighlight(keys) {
45740
+ this.regionMeshes.setHighlight(keys);
45741
+ }
45742
+ setRegionHidden(keys) {
45743
+ this.regionMeshes.setHidden(keys);
45744
+ }
45745
+ /** As the Volume view: the reference volume hides the surfaces behind what it shows, or none. */
45746
+ setRegionsThroughVolume(through) {
45747
+ this.regionsThrough = through;
45748
+ if (this.volume)
45749
+ this.volume.blending = through ? 'translucent_no_depth' : 'translucent';
45750
+ this.ctx.viewer.requestRender();
45751
+ }
45752
+ /**
45753
+ * Place the region surfaces in the reference volume's box, as the Volume view places them in its
45754
+ * own: image pixel (x, y) is voxel (x, y), slice k is voxel plane k, and the box is the volume's
45755
+ * voxels times its voxel size, centred on the origin as napari centres the volume (and the cloud
45756
+ * is offset to sit in it). Only when the plotted image IS that volume's stack: regions drawn on
45757
+ * another image (a tissue image) have no place in it, and are not shown - nor are surfaces
45758
+ * placed in a volume this one replaced.
45759
+ */
45760
+ placeRegionMeshes(dataset) {
45761
+ const meta = dataset.volume;
45762
+ if (!meta || !this.isVolumeStack(meta)) {
45763
+ this.regionMeshes.clearFrame();
45764
+ return;
45765
+ }
45766
+ const [vx, vy, vz] = meta.voxelSize;
45767
+ this.regionMeshes.setFrame({
45768
+ size: [meta.width, meta.height, meta.depth],
45769
+ world: [meta.width * vx, meta.height * vy, meta.depth * vz],
45770
+ });
45771
+ }
45772
+ /**
45773
+ * Image px per voxel of the volume the surfaces sit in: 1, the reference volume being the stack's
45774
+ * own voxels, as the Volume view's is at full resolution, so both reconstruct a class alike (and
45775
+ * share its cached surface). Null while there is no volume stack to place them in.
45776
+ */
45777
+ voxelStep() {
45778
+ const meta = this.session.latest?.[0]?.volume;
45779
+ return this.volume && meta && this.isVolumeStack(meta) ? 1 : null;
45780
+ }
45781
+ /** Whether the plotted image is the reference volume's stack: its size and slice count. */
45782
+ isVolumeStack(meta) {
45783
+ const info = this.info;
45784
+ const [w, h] = info?.trueImageSize ?? [0, 0];
45785
+ return !!info?.isStack && w === meta.width && h === meta.height && (info.urls?.length ?? 0) === meta.depth;
44774
45786
  }
44775
45787
  async mount() {
44776
45788
  // Before the spatial subscription, so the first build already reads the current value.
@@ -44821,6 +45833,7 @@ class Spatial3dScene extends SpatialSceneBase {
44821
45833
  return out;
44822
45834
  }
44823
45835
  dispose() {
45836
+ this.regionMeshes.clear();
44824
45837
  this.disposeSpatial();
44825
45838
  this.invertSub?.unsubscribe();
44826
45839
  this.invertSub = null;
@@ -45356,6 +46369,8 @@ class Spatial3dScene extends SpatialSceneBase {
45356
46369
  this.volume = null;
45357
46370
  this.volumeKey = null;
45358
46371
  }
46372
+ // The surfaces sat in the volume just removed (or there was none): down until one is placed.
46373
+ this.regionMeshes.clearFrame();
45359
46374
  this.origin = [0, 0, 0];
45360
46375
  if (!meta || !port?.getVolume)
45361
46376
  return;
@@ -45382,14 +46397,18 @@ class Spatial3dScene extends SpatialSceneBase {
45382
46397
  opacity: view.volumeOpacity,
45383
46398
  voxelSize: [vx, vy, vz],
45384
46399
  });
46400
+ if (this.regionsThrough)
46401
+ this.volume.blending = 'translucent_no_depth';
45385
46402
  // Half the box, negated: the observations' origin is the box's near corner,
45386
46403
  // and the box is centred on the world origin.
45387
46404
  this.origin = [-(meta.width * vx) / 2, -(meta.height * vy) / 2, -(meta.depth * vz) / 2];
46405
+ this.placeRegionMeshes(dataset);
45388
46406
  // Force a geometry rebuild: the offset changed, so cached positions are stale.
45389
46407
  this.layerKey = null;
45390
46408
  }
45391
46409
  /** Drop every layer of the cloud (the dataset went away, or has no z). */
45392
46410
  removeLayers(viewer) {
46411
+ this.regionMeshes.clearFrame();
45393
46412
  for (const layer of [this.points, this.volume, this.geneMapLayer, ...this.densityLayers]) {
45394
46413
  if (layer)
45395
46414
  viewer.layers.remove(layer);
@@ -45733,7 +46752,7 @@ class NapariVisualizerService extends BaseStoreVisualizer {
45733
46752
  /** The scene a plot type mounts. */
45734
46753
  createScene(plotType, ctx, info, noImage) {
45735
46754
  if (isSpatialOmics3d(plotType))
45736
- return new Spatial3dScene(ctx, this.spatial);
46755
+ return new Spatial3dScene(ctx, this.spatial, info);
45737
46756
  if (isSpatialOmics(plotType))
45738
46757
  return new Spatial2dScene(ctx, this.spatial, noImage);
45739
46758
  if (isNapariScatter(plotType))
@@ -46654,8 +47673,14 @@ class RoutingVisualizerService {
46654
47673
  this.napariFellBack = false;
46655
47674
  /** The div the backends render into (every backend shares it); '' before the first plot. */
46656
47675
  this.plotDiv = '';
46657
- /** Whether a napari volume view is the current plot type. */
47676
+ /**
47677
+ * Whether regions can be shown in 3D on screen: a napari volume (or isosurface) view, or the 3D
47678
+ * spatial cloud over a stack image (its reference volume's, see showVolumeAsImage). Both draw the
47679
+ * region reconstructions the same way (VolumeRegionMeshes), in the volume's box.
47680
+ */
46658
47681
  this.volumeView$ = new BehaviorSubject(false);
47682
+ /** Whether a napari volume (or isosurface) view is the current plot type: the slice pane's. */
47683
+ this.imageVolumeView = false;
46659
47684
  /** Whether the volume view's slice pane is wanted (see {@link setSliceEditor}). */
46660
47685
  this.sliceEditor$ = new BehaviorSubject(false);
46661
47686
  /** Whether the volume view draws the region reconstructions through the volume. */
@@ -46881,13 +47906,13 @@ class RoutingVisualizerService {
46881
47906
  }
46882
47907
  plot(plotDiv, imageLoaded, imageInfo, screenHeight, plotType, inPlace) {
46883
47908
  this.currentPlotType = plotType;
46884
- this.volumeView$.next(isNapariVolume(plotType) || isNapariIsosurface(plotType));
46885
47909
  this.plottedFileName = imageInfo?.fileName;
46886
47910
  this.plottedInfo = imageInfo ?? null;
46887
47911
  this.plottedHeight = screenHeight;
47912
+ this.updateVolumeView(plotType);
46888
47913
  // The slice pane belongs to the volume view: another plot type takes it down (OSD may be
46889
47914
  // about to draw the main view, which must not find the pane's viewer still mounted).
46890
- if (!this.volumeView$.value)
47915
+ if (!this.imageVolumeView)
46891
47916
  this.unmountSlice();
46892
47917
  // Region surfaces belong to the image they were built from: another image drops them.
46893
47918
  if (this.classes3d$.value.length && this.plottedFileName !== this.classes3dImage)
@@ -46910,7 +47935,7 @@ class RoutingVisualizerService {
46910
47935
  }
46911
47936
  this.lastRendered = next;
46912
47937
  const plotted = next.plot(plotDiv, imageLoaded, imageInfo, screenHeight, plotType, inPlace);
46913
- if (!this.sliceDiv || !this.volumeView$.value)
47938
+ if (!this.sliceDiv || !this.imageVolumeView)
46914
47939
  return plotted;
46915
47940
  // The split volume view: the slice pane follows the image (a new one, or its sharper pass).
46916
47941
  return plotted.then((ok) => {
@@ -46942,7 +47967,7 @@ class RoutingVisualizerService {
46942
47967
  async mountSlice(inPlace) {
46943
47968
  const div = this.sliceDiv;
46944
47969
  const info = this.plottedInfo;
46945
- if (!div || !info?.urls?.length || !this.volumeView$.value)
47970
+ if (!div || !info?.urls?.length || !this.imageVolumeView)
46946
47971
  return false;
46947
47972
  const request = ++this.sliceRequest;
46948
47973
  try {
@@ -47064,7 +48089,7 @@ class RoutingVisualizerService {
47064
48089
  }
47065
48090
  setPlotType(plotType) {
47066
48091
  this.currentPlotType = plotType;
47067
- this.volumeView$.next(isNapariVolume(plotType) || isNapariIsosurface(plotType));
48092
+ this.updateVolumeView(plotType);
47068
48093
  this.plotly.setPlotType(plotType);
47069
48094
  }
47070
48095
  /** @deprecated Use `getSurface3dControls()`. */
@@ -47367,6 +48392,13 @@ class RoutingVisualizerService {
47367
48392
  this.regionStore.setRegionStyle(patch);
47368
48393
  }
47369
48394
  // ── Regions in 3D: their reconstructions shown in the volume view ─────
48395
+ /** Recompute which 3D view is on screen for `plotType` (see {@link volumeView$}). */
48396
+ updateVolumeView(plotType) {
48397
+ this.imageVolumeView = isNapariVolume(plotType) || isNapariIsosurface(plotType);
48398
+ const regions3d = this.imageVolumeView || (isSpatialOmics3d(plotType) && !!this.plottedInfo?.isStack);
48399
+ // Every plot, as before: the Region Editor drops its per-class volumes on each (a new image).
48400
+ this.volumeView$.next(regions3d);
48401
+ }
47370
48402
  /** Whether a napari volume (or isosurface) view is on screen: regions can be shown in 3D. */
47371
48403
  getVolumeView$() {
47372
48404
  return this.volumeView$.asObservable();
@@ -48026,5 +49058,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImpor
48026
49058
  * Generated bundle index. Do not edit.
48027
49059
  */
48028
49060
 
48029
- export { TOOLBAR_TOOLS as $, ALL_GENES as A, REGION_IO_PORT as B, CANVAS_TOOL_IDS as C, DEFAULT_CATEGORICAL_PALETTE as D, REGION_SOURCE_MIN_CHARS as E, Rectangle as F, Region as G, HexColorPickerComponent as H, IMAGE_STATE_PORT as I, RegionEditorComponent as J, RoutingVisualizerService as K, LUT_COLORS as L, MISSING_COLOR as M, NAPARI_DECIMATE_OPTIONS as N, SEMANTIC_SEGMENTER as O, PLOT_MODE_CONTEXT as P, SPATIAL_DATA_PORT as Q, REGION_EDITOR_API as R, SAM_MODELS as S, SPATIAL_WIRE_VERSION as T, ShapeSelection as U, SpatialChartsComponent as V, SpatialControlsComponent as W, SpatialDataHttpService as X, SpatialSelectionStore as Y, SupersededError as Z, TILE_ACCESS_PORT as _, runDecoder as a, provideVisualization as a$, VISUALIZER as a0, VIZ_ALERT_TOAST_KEY as a1, VIZ_CONFIG as a2, VIZ_TOAST_KEY as a3, ViewerFeature as a4, VisualizationModule as a5, VisualizerComponent as a6, assertManifestVersion as a7, benefitsFromGrouping as a8, buildOmicsTraces as a9, isContinuousColumn as aA, isDialogToolContribution as aB, isEmptyPresetSet as aC, isLabelVolumeName as aD, isLittleEndian as aE, isNapari3d as aF, isNapariIsosurface as aG, isNapariScatter as aH, isNapariScatter3d as aI, isNapariSurface as aJ, isNapariVolume as aK, isSamModelReady as aL, isSpatialOmics as aM, isSpatialOmics3d as aN, isThreeDimensional as aO, labelClasses as aP, labelSliceGeoJson as aQ, lutFor as aR, markerDiameters as aS, maskToIndices as aT, mutedFromSelection as aU, omicsLayout as aV, openGeoJsonRegionSource as aW, openHttpRegionSource as aX, parsePresetSet as aY, planRegionImport as aZ, pointInRing as a_, capabilitiesOf as aa, clearSamModelCache as ab, contrastWindow as ac, contributedPlotTypeOption as ad, countMask as ae, cropImage as af, datasetFromManifest as ag, decodeColumn as ah, decodeCoords as ai, decodeFeatureVector as aj, decodePolygons as ak, decodeRadius as al, defaultPresetSet as am, dialogToolContributions as an, emptySelection as ao, encodeCategorical as ap, encodeContinuous as aq, findColumnMeta as ar, getDefaultSamModelId as as, getPlotTypeDescriptor as at, getSamModel as au, hydrateBounds as av, importFile as aw, isBuiltinPlotType as ax, isCanvasToolId as ay, isCategoricalColumn as az, ALL_TOOLBAR_TOOLS as b, readLabelVolume as b0, rendererOwnsWheel as b1, resolveCategoryColors as b2, selectByCategory as b3, selectInRegions as b4, setDefaultSamModel as b5, setOrtWasmBase as b6, setSamModelUrls as b7, sortToolContributions as b8, visibleToolContributions as b9, CELL_SEGMENTER as c, CHANNEL_HISTOGRAM_API as d, CellposeSegmenterService as e, fetchModel as f, getOrtWasmBase as g, ChannelHistogramComponent as h, DEFAULT_FALLBACK_PALETTE as i, DEFAULT_MUTED_OPACITY as j, DEFAULT_SAM_MODEL_ID as k, DEFAULT_SPATIAL_VIEW as l, INSTANCE_SEGMENTER as m, ImageConverterService as n, MultiPolygon as o, NAPARI_DEFAULT_DECIMATE as p, NO_CATEGORY as q, runEncoder as r, NO_OBSERVATION as s, PLOT_MODE_SESSION as t, PLOT_TYPE_CONTRIBUTIONS as u, PLOT_TYPE_DESCRIPTORS as v, PREFERENCES_PORT as w, PlotType as x, Polygon as y, ProcessingImage as z };
48030
- //# sourceMappingURL=jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-BELcx0jv.mjs.map
49061
+ export { TILE_ACCESS_PORT as $, ALL_GENES as A, ProcessingImage as B, CANVAS_TOOL_IDS as C, DEFAULT_CATEGORICAL_PALETTE as D, REGION_IO_PORT as E, REGION_SOURCE_MIN_CHARS as F, Rectangle as G, HexColorPickerComponent as H, IMAGE_STATE_PORT as I, Region as J, RegionEditorComponent as K, LUT_COLORS as L, MISSING_COLOR as M, NAPARI_DECIMATE_OPTIONS as N, RoutingVisualizerService as O, PLOT_MODE_CONTEXT as P, SEMANTIC_SEGMENTER as Q, REGION_EDITOR_API as R, SAM_MODELS as S, SPATIAL_DATA_PORT as T, SPATIAL_WIRE_VERSION as U, ShapeSelection as V, SpatialChartsComponent as W, SpatialControlsComponent as X, SpatialDataHttpService as Y, SpatialSelectionStore as Z, SupersededError as _, runDecoder as a, openGeoJsonRegionSource as a$, TOOLBAR_TOOLS as a0, VISUALIZER as a1, VIZ_ALERT_TOAST_KEY as a2, VIZ_CONFIG as a3, VIZ_TOAST_KEY as a4, ViewerFeature as a5, VisualizationModule as a6, VisualizerComponent as a7, assertManifestVersion as a8, benefitsFromGrouping as a9, isBuiltinPlotType as aA, isCanvasToolId as aB, isCategoricalColumn as aC, isContinuousColumn as aD, isDialogToolContribution as aE, isEmptyPresetSet as aF, isLabelVolumeName as aG, isLittleEndian as aH, isNapari3d as aI, isNapariIsosurface as aJ, isNapariScatter as aK, isNapariScatter3d as aL, isNapariSurface as aM, isNapariVolume as aN, isSamModelReady as aO, isSpatialOmics as aP, isSpatialOmics3d as aQ, isThreeDimensional as aR, isZipName as aS, labelClasses as aT, labelSliceGeoJson as aU, lutFor as aV, markerDiameters as aW, maskToIndices as aX, mutedFromSelection as aY, omicsLayout as aZ, openAnnotations as a_, buildOmicsTraces as aa, capabilitiesOf as ab, clearSamModelCache as ac, contrastWindow as ad, contributedPlotTypeOption as ae, countMask as af, cropImage as ag, datasetFromManifest as ah, decodeColumn as ai, decodeCoords as aj, decodeFeatureVector as ak, decodePolygons as al, decodeRadius as am, defaultPresetSet as an, dialogToolContributions as ao, emptySelection as ap, encodeCategorical as aq, encodeContinuous as ar, expandZips as as, findColumnMeta as at, getDefaultSamModelId as au, getPlotTypeDescriptor as av, getSamModel as aw, hasZarrMetadata as ax, hydrateBounds as ay, importFile as az, ALL_TOOLBAR_TOOLS as b, openHttpRegionSource as b0, parsePresetSet as b1, planRegionImport as b2, pointInRing as b3, provideVisualization as b4, readAnnotationSlices as b5, readLabelVolume as b6, readOmeZarrLabels as b7, readZip as b8, rendererOwnsWheel as b9, resolveCategoryColors as ba, selectByCategory as bb, selectInRegions as bc, setDefaultSamModel as bd, setOrtWasmBase as be, setSamModelUrls as bf, sortToolContributions as bg, visibleToolContributions as bh, ANNOTATION_IMAGE_MAX_DIM as c, CELL_SEGMENTER as d, CHANNEL_HISTOGRAM_API as e, fetchModel as f, getOrtWasmBase as g, CellposeSegmenterService as h, ChannelHistogramComponent as i, DEFAULT_FALLBACK_PALETTE as j, DEFAULT_MUTED_OPACITY as k, DEFAULT_SAM_MODEL_ID as l, DEFAULT_SPATIAL_VIEW as m, INSTANCE_SEGMENTER as n, ImageConverterService as o, MultiPolygon as p, NAPARI_DEFAULT_DECIMATE as q, runEncoder as r, NO_CATEGORY as s, NO_OBSERVATION as t, PLOT_MODE_SESSION as u, PLOT_TYPE_CONTRIBUTIONS as v, PLOT_TYPE_DESCRIPTORS as w, PREFERENCES_PORT as x, PlotType as y, Polygon as z };
49062
+ //# sourceMappingURL=jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-DnPlIP2F.mjs.map