@jax-data-science/sci-image-visualizer 0.11.0 → 0.12.0
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.
- package/esm2022/index.mjs +4 -1
- package/esm2022/lib/contracts/image.contract.mjs +1 -1
- package/esm2022/lib/implementations/napari-js/napari-spatial-3d-scene.mjs +70 -2
- package/esm2022/lib/implementations/napari-js/napari-visualizer.service.mjs +2 -2
- package/esm2022/lib/implementations/napari-js/napari-volume-region-meshes.mjs +11 -1
- package/esm2022/lib/region-editor/region-editor.component.mjs +46 -41
- package/esm2022/lib/region-import/label-volume.mjs +35 -4
- package/esm2022/lib/region-import/ome-zarr-labels.mjs +253 -0
- package/esm2022/lib/region-import/open-annotations.mjs +289 -0
- package/esm2022/lib/region-import/zarr-codecs.mjs +167 -0
- package/esm2022/lib/region-import/zip.mjs +157 -0
- package/esm2022/lib/routing-visualizer.service.mjs +20 -7
- package/esm2022/lib/visualizer/region-load.mjs +16 -1
- 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-a68Hw5rw.mjs} +1050 -50
- package/fesm2022/jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-a68Hw5rw.mjs.map +1 -0
- package/fesm2022/{jax-data-science-sci-image-visualizer-onnx-sam-session-DEDSm_NL.mjs → jax-data-science-sci-image-visualizer-onnx-sam-session-CvadfzjS.mjs} +4 -2
- package/fesm2022/{jax-data-science-sci-image-visualizer-onnx-sam-session-DEDSm_NL.mjs.map → jax-data-science-sci-image-visualizer-onnx-sam-session-CvadfzjS.mjs.map} +1 -1
- package/fesm2022/jax-data-science-sci-image-visualizer.mjs +3 -1
- package/fesm2022/jax-data-science-sci-image-visualizer.mjs.map +1 -1
- package/index.d.ts +6 -0
- package/lib/contracts/image.contract.d.ts +7 -0
- package/lib/implementations/napari-js/napari-spatial-3d-scene.d.ts +40 -1
- package/lib/implementations/napari-js/napari-volume-region-meshes.d.ts +5 -0
- package/lib/region-editor/region-editor.component.d.ts +5 -4
- package/lib/region-import/label-volume.d.ts +4 -0
- package/lib/region-import/ome-zarr-labels.d.ts +36 -0
- package/lib/region-import/open-annotations.d.ts +73 -0
- package/lib/region-import/zarr-codecs.d.ts +14 -0
- package/lib/region-import/zip.d.ts +31 -0
- package/lib/routing-visualizer.service.d.ts +9 -1
- package/package.json +2 -1
- 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
|
-
|
|
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-
|
|
10402
|
+
const { OnnxSamSession } = await import('./jax-data-science-sci-image-visualizer-onnx-sam-session-CvadfzjS.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
|
|
15912
|
-
* region source per slice,
|
|
15913
|
-
*
|
|
15914
|
-
*
|
|
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
|
-
|
|
15918
|
-
|
|
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
|
|
15936
|
-
|
|
15937
|
-
|
|
15938
|
-
|
|
15939
|
-
|
|
15940
|
-
|
|
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
|
-
|
|
15947
|
-
|
|
15948
|
-
|
|
15949
|
-
|
|
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
|
-
|
|
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
|
|
@@ -40407,6 +41317,16 @@ class VolumeRegionMeshes {
|
|
|
40407
41317
|
this.remove(key, e.layer);
|
|
40408
41318
|
this.wanted = [];
|
|
40409
41319
|
}
|
|
41320
|
+
/**
|
|
41321
|
+
* The volume the surfaces sat in is gone (or no longer theirs): take every surface down, keeping
|
|
41322
|
+
* the meshes wanted, so they come back when a frame is set again.
|
|
41323
|
+
*/
|
|
41324
|
+
clearFrame() {
|
|
41325
|
+
this.frame = null;
|
|
41326
|
+
for (const [key, e] of [...this.layers])
|
|
41327
|
+
this.remove(key, e.layer);
|
|
41328
|
+
this.viewer.requestRender();
|
|
41329
|
+
}
|
|
40410
41330
|
sync() {
|
|
40411
41331
|
const keep = new Map(this.wanted.map((m) => [m.key, m]));
|
|
40412
41332
|
for (const [key, e] of [...this.layers]) {
|
|
@@ -44725,8 +45645,13 @@ function slabAt(dataset, slice) {
|
|
|
44725
45645
|
* shape while keeping the selection. The first 3D layer added frames the camera (`fit3d: 'once'`).
|
|
44726
45646
|
*/
|
|
44727
45647
|
class Spatial3dScene extends SpatialSceneBase {
|
|
44728
|
-
|
|
45648
|
+
/**
|
|
45649
|
+
* @param info the image plotted with the cloud: the reference volume's stack (the 2D view's
|
|
45650
|
+
* image, see showVolumeAsImage) when the dataset has a volume, which the regions are on
|
|
45651
|
+
*/
|
|
45652
|
+
constructor(ctx, session, info) {
|
|
44729
45653
|
super(ctx, session, true);
|
|
45654
|
+
this.info = info;
|
|
44730
45655
|
/** The 3D point cloud. One layer: selection is a per-point alpha on it, not a second. */
|
|
44731
45656
|
this.points = null;
|
|
44732
45657
|
this.layerKey = null;
|
|
@@ -44768,9 +45693,64 @@ class Spatial3dScene extends SpatialSceneBase {
|
|
|
44768
45693
|
* one; a rebuild that leaves the key alone lets it finish. */
|
|
44769
45694
|
this.densityLoads = new Supersede();
|
|
44770
45695
|
this.geneMapLoads = new Supersede();
|
|
45696
|
+
/** Whether the reference volume lets every region surface through (see setRegionsThroughVolume). */
|
|
45697
|
+
this.regionsThrough = false;
|
|
44771
45698
|
/** The store's invert setting, which the density volumes follow (no other subscription of
|
|
44772
45699
|
* this scene carries it). */
|
|
44773
45700
|
this.invertSub = null;
|
|
45701
|
+
this.regionMeshes = new VolumeRegionMeshes(ctx.viewer);
|
|
45702
|
+
}
|
|
45703
|
+
/** Show these region reconstructions with the cloud (replacing the ones shown). */
|
|
45704
|
+
setRegionMeshes(meshes) {
|
|
45705
|
+
this.regionMeshes.set(meshes);
|
|
45706
|
+
}
|
|
45707
|
+
setRegionHighlight(keys) {
|
|
45708
|
+
this.regionMeshes.setHighlight(keys);
|
|
45709
|
+
}
|
|
45710
|
+
setRegionHidden(keys) {
|
|
45711
|
+
this.regionMeshes.setHidden(keys);
|
|
45712
|
+
}
|
|
45713
|
+
/** As the Volume view: the reference volume hides the surfaces behind what it shows, or none. */
|
|
45714
|
+
setRegionsThroughVolume(through) {
|
|
45715
|
+
this.regionsThrough = through;
|
|
45716
|
+
if (this.volume)
|
|
45717
|
+
this.volume.blending = through ? 'translucent_no_depth' : 'translucent';
|
|
45718
|
+
this.ctx.viewer.requestRender();
|
|
45719
|
+
}
|
|
45720
|
+
/**
|
|
45721
|
+
* Place the region surfaces in the reference volume's box, as the Volume view places them in its
|
|
45722
|
+
* own: image pixel (x, y) is voxel (x, y), slice k is voxel plane k, and the box is the volume's
|
|
45723
|
+
* voxels times its voxel size, centred on the origin as napari centres the volume (and the cloud
|
|
45724
|
+
* is offset to sit in it). Only when the plotted image IS that volume's stack: regions drawn on
|
|
45725
|
+
* another image (a tissue image) have no place in it, and are not shown - nor are surfaces
|
|
45726
|
+
* placed in a volume this one replaced.
|
|
45727
|
+
*/
|
|
45728
|
+
placeRegionMeshes(dataset) {
|
|
45729
|
+
const meta = dataset.volume;
|
|
45730
|
+
if (!meta || !this.isVolumeStack(meta)) {
|
|
45731
|
+
this.regionMeshes.clearFrame();
|
|
45732
|
+
return;
|
|
45733
|
+
}
|
|
45734
|
+
const [vx, vy, vz] = meta.voxelSize;
|
|
45735
|
+
this.regionMeshes.setFrame({
|
|
45736
|
+
size: [meta.width, meta.height, meta.depth],
|
|
45737
|
+
world: [meta.width * vx, meta.height * vy, meta.depth * vz],
|
|
45738
|
+
});
|
|
45739
|
+
}
|
|
45740
|
+
/**
|
|
45741
|
+
* Image px per voxel of the volume the surfaces sit in: 1, the reference volume being the stack's
|
|
45742
|
+
* own voxels, as the Volume view's is at full resolution, so both reconstruct a class alike (and
|
|
45743
|
+
* share its cached surface). Null while there is no volume stack to place them in.
|
|
45744
|
+
*/
|
|
45745
|
+
voxelStep() {
|
|
45746
|
+
const meta = this.session.latest?.[0]?.volume;
|
|
45747
|
+
return this.volume && meta && this.isVolumeStack(meta) ? 1 : null;
|
|
45748
|
+
}
|
|
45749
|
+
/** Whether the plotted image is the reference volume's stack: its size and slice count. */
|
|
45750
|
+
isVolumeStack(meta) {
|
|
45751
|
+
const info = this.info;
|
|
45752
|
+
const [w, h] = info?.trueImageSize ?? [0, 0];
|
|
45753
|
+
return !!info?.isStack && w === meta.width && h === meta.height && (info.urls?.length ?? 0) === meta.depth;
|
|
44774
45754
|
}
|
|
44775
45755
|
async mount() {
|
|
44776
45756
|
// Before the spatial subscription, so the first build already reads the current value.
|
|
@@ -44821,6 +45801,7 @@ class Spatial3dScene extends SpatialSceneBase {
|
|
|
44821
45801
|
return out;
|
|
44822
45802
|
}
|
|
44823
45803
|
dispose() {
|
|
45804
|
+
this.regionMeshes.clear();
|
|
44824
45805
|
this.disposeSpatial();
|
|
44825
45806
|
this.invertSub?.unsubscribe();
|
|
44826
45807
|
this.invertSub = null;
|
|
@@ -45356,6 +46337,8 @@ class Spatial3dScene extends SpatialSceneBase {
|
|
|
45356
46337
|
this.volume = null;
|
|
45357
46338
|
this.volumeKey = null;
|
|
45358
46339
|
}
|
|
46340
|
+
// The surfaces sat in the volume just removed (or there was none): down until one is placed.
|
|
46341
|
+
this.regionMeshes.clearFrame();
|
|
45359
46342
|
this.origin = [0, 0, 0];
|
|
45360
46343
|
if (!meta || !port?.getVolume)
|
|
45361
46344
|
return;
|
|
@@ -45382,14 +46365,18 @@ class Spatial3dScene extends SpatialSceneBase {
|
|
|
45382
46365
|
opacity: view.volumeOpacity,
|
|
45383
46366
|
voxelSize: [vx, vy, vz],
|
|
45384
46367
|
});
|
|
46368
|
+
if (this.regionsThrough)
|
|
46369
|
+
this.volume.blending = 'translucent_no_depth';
|
|
45385
46370
|
// Half the box, negated: the observations' origin is the box's near corner,
|
|
45386
46371
|
// and the box is centred on the world origin.
|
|
45387
46372
|
this.origin = [-(meta.width * vx) / 2, -(meta.height * vy) / 2, -(meta.depth * vz) / 2];
|
|
46373
|
+
this.placeRegionMeshes(dataset);
|
|
45388
46374
|
// Force a geometry rebuild: the offset changed, so cached positions are stale.
|
|
45389
46375
|
this.layerKey = null;
|
|
45390
46376
|
}
|
|
45391
46377
|
/** Drop every layer of the cloud (the dataset went away, or has no z). */
|
|
45392
46378
|
removeLayers(viewer) {
|
|
46379
|
+
this.regionMeshes.clearFrame();
|
|
45393
46380
|
for (const layer of [this.points, this.volume, this.geneMapLayer, ...this.densityLayers]) {
|
|
45394
46381
|
if (layer)
|
|
45395
46382
|
viewer.layers.remove(layer);
|
|
@@ -45733,7 +46720,7 @@ class NapariVisualizerService extends BaseStoreVisualizer {
|
|
|
45733
46720
|
/** The scene a plot type mounts. */
|
|
45734
46721
|
createScene(plotType, ctx, info, noImage) {
|
|
45735
46722
|
if (isSpatialOmics3d(plotType))
|
|
45736
|
-
return new Spatial3dScene(ctx, this.spatial);
|
|
46723
|
+
return new Spatial3dScene(ctx, this.spatial, info);
|
|
45737
46724
|
if (isSpatialOmics(plotType))
|
|
45738
46725
|
return new Spatial2dScene(ctx, this.spatial, noImage);
|
|
45739
46726
|
if (isNapariScatter(plotType))
|
|
@@ -46654,8 +47641,14 @@ class RoutingVisualizerService {
|
|
|
46654
47641
|
this.napariFellBack = false;
|
|
46655
47642
|
/** The div the backends render into (every backend shares it); '' before the first plot. */
|
|
46656
47643
|
this.plotDiv = '';
|
|
46657
|
-
/**
|
|
47644
|
+
/**
|
|
47645
|
+
* Whether regions can be shown in 3D on screen: a napari volume (or isosurface) view, or the 3D
|
|
47646
|
+
* spatial cloud over a stack image (its reference volume's, see showVolumeAsImage). Both draw the
|
|
47647
|
+
* region reconstructions the same way (VolumeRegionMeshes), in the volume's box.
|
|
47648
|
+
*/
|
|
46658
47649
|
this.volumeView$ = new BehaviorSubject(false);
|
|
47650
|
+
/** Whether a napari volume (or isosurface) view is the current plot type: the slice pane's. */
|
|
47651
|
+
this.imageVolumeView = false;
|
|
46659
47652
|
/** Whether the volume view's slice pane is wanted (see {@link setSliceEditor}). */
|
|
46660
47653
|
this.sliceEditor$ = new BehaviorSubject(false);
|
|
46661
47654
|
/** Whether the volume view draws the region reconstructions through the volume. */
|
|
@@ -46881,13 +47874,13 @@ class RoutingVisualizerService {
|
|
|
46881
47874
|
}
|
|
46882
47875
|
plot(plotDiv, imageLoaded, imageInfo, screenHeight, plotType, inPlace) {
|
|
46883
47876
|
this.currentPlotType = plotType;
|
|
46884
|
-
this.volumeView$.next(isNapariVolume(plotType) || isNapariIsosurface(plotType));
|
|
46885
47877
|
this.plottedFileName = imageInfo?.fileName;
|
|
46886
47878
|
this.plottedInfo = imageInfo ?? null;
|
|
46887
47879
|
this.plottedHeight = screenHeight;
|
|
47880
|
+
this.updateVolumeView(plotType);
|
|
46888
47881
|
// The slice pane belongs to the volume view: another plot type takes it down (OSD may be
|
|
46889
47882
|
// about to draw the main view, which must not find the pane's viewer still mounted).
|
|
46890
|
-
if (!this.
|
|
47883
|
+
if (!this.imageVolumeView)
|
|
46891
47884
|
this.unmountSlice();
|
|
46892
47885
|
// Region surfaces belong to the image they were built from: another image drops them.
|
|
46893
47886
|
if (this.classes3d$.value.length && this.plottedFileName !== this.classes3dImage)
|
|
@@ -46910,7 +47903,7 @@ class RoutingVisualizerService {
|
|
|
46910
47903
|
}
|
|
46911
47904
|
this.lastRendered = next;
|
|
46912
47905
|
const plotted = next.plot(plotDiv, imageLoaded, imageInfo, screenHeight, plotType, inPlace);
|
|
46913
|
-
if (!this.sliceDiv || !this.
|
|
47906
|
+
if (!this.sliceDiv || !this.imageVolumeView)
|
|
46914
47907
|
return plotted;
|
|
46915
47908
|
// The split volume view: the slice pane follows the image (a new one, or its sharper pass).
|
|
46916
47909
|
return plotted.then((ok) => {
|
|
@@ -46942,7 +47935,7 @@ class RoutingVisualizerService {
|
|
|
46942
47935
|
async mountSlice(inPlace) {
|
|
46943
47936
|
const div = this.sliceDiv;
|
|
46944
47937
|
const info = this.plottedInfo;
|
|
46945
|
-
if (!div || !info?.urls?.length || !this.
|
|
47938
|
+
if (!div || !info?.urls?.length || !this.imageVolumeView)
|
|
46946
47939
|
return false;
|
|
46947
47940
|
const request = ++this.sliceRequest;
|
|
46948
47941
|
try {
|
|
@@ -47064,7 +48057,7 @@ class RoutingVisualizerService {
|
|
|
47064
48057
|
}
|
|
47065
48058
|
setPlotType(plotType) {
|
|
47066
48059
|
this.currentPlotType = plotType;
|
|
47067
|
-
this.
|
|
48060
|
+
this.updateVolumeView(plotType);
|
|
47068
48061
|
this.plotly.setPlotType(plotType);
|
|
47069
48062
|
}
|
|
47070
48063
|
/** @deprecated Use `getSurface3dControls()`. */
|
|
@@ -47367,6 +48360,13 @@ class RoutingVisualizerService {
|
|
|
47367
48360
|
this.regionStore.setRegionStyle(patch);
|
|
47368
48361
|
}
|
|
47369
48362
|
// ── Regions in 3D: their reconstructions shown in the volume view ─────
|
|
48363
|
+
/** Recompute which 3D view is on screen for `plotType` (see {@link volumeView$}). */
|
|
48364
|
+
updateVolumeView(plotType) {
|
|
48365
|
+
this.imageVolumeView = isNapariVolume(plotType) || isNapariIsosurface(plotType);
|
|
48366
|
+
const regions3d = this.imageVolumeView || (isSpatialOmics3d(plotType) && !!this.plottedInfo?.isStack);
|
|
48367
|
+
// Every plot, as before: the Region Editor drops its per-class volumes on each (a new image).
|
|
48368
|
+
this.volumeView$.next(regions3d);
|
|
48369
|
+
}
|
|
47370
48370
|
/** Whether a napari volume (or isosurface) view is on screen: regions can be shown in 3D. */
|
|
47371
48371
|
getVolumeView$() {
|
|
47372
48372
|
return this.volumeView$.asObservable();
|
|
@@ -48026,5 +49026,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.9", ngImpor
|
|
|
48026
49026
|
* Generated bundle index. Do not edit.
|
|
48027
49027
|
*/
|
|
48028
49028
|
|
|
48029
|
-
export {
|
|
48030
|
-
//# sourceMappingURL=jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-
|
|
49029
|
+
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 };
|
|
49030
|
+
//# sourceMappingURL=jax-data-science-sci-image-visualizer-jax-data-science-sci-image-visualizer-a68Hw5rw.mjs.map
|