@niivue/nv-ext-tile 0.0.0-stage → 1.0.0-rc.2
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/README.md +156 -2
- package/dist/dcm2niixDecoders.d.ts +8 -0
- package/dist/dicom.d.ts +5 -0
- package/dist/dicom.js +710 -0
- package/dist/dicomIndex.d.ts +98 -0
- package/dist/dicomWsiSource.d.ts +40 -0
- package/dist/hostedManifest.d.ts +10 -0
- package/dist/tiff.d.ts +43 -0
- package/dist/tiff.js +279 -0
- package/package.json +46 -4
package/README.md
CHANGED
|
@@ -1,3 +1,157 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @niivue/nv-ext-tile
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tiled image formats for NiiVue's `NVSlide`, one subpath per format, so a page loads only the format it uses:
|
|
4
|
+
- `@niivue/nv-ext-tile/tiff`: tiled TIFF (below).
|
|
5
|
+
- `@niivue/nv-ext-tile/dicom`: DICOM-WSI folders and hosted manifests ([Local DICOM-WSI folders](#local-dicom-wsi-folders)). It is a whole-slide adapter, not a DICOM loader: convert other DICOM to NIfTI with `@niivue/dcm2niix`.
|
|
6
|
+
|
|
7
|
+
Replaces `@niivue/nv-ext-wsi` and `@niivue/nv-ext-tiff`; the APIs are unchanged, only the import paths differ.
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
bun add @niivue/nv-ext-tile
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires `@niivue/niivue` as a peer dependency, at least the core version current when this package was published (`workspace:^`, which `bun publish` writes as `^<that version>`).
|
|
16
|
+
|
|
17
|
+
## TIFF
|
|
18
|
+
|
|
19
|
+
Stream large tiled/pyramidal TIFF, BigTIFF, OME-TIFF and Aperio SVS whole-slide images into NiiVue's `NVSlide`, one tile at a time, using [geotiff](https://github.com/geotiffjs/geotiff.js).
|
|
20
|
+
|
|
21
|
+
Core NiiVue already reads TIFF volumes (strips, tiles, LZW, Deflate, PackBits) with no dependencies, but decodes the whole file. This package is for slides too large to fetch: it reads only the IFDs and the tiles in view, over HTTP Range, and adds the compressions core lacks (JPEG with `JPEGTables`, ZSTD, WebP, LERC). Apps that need it pay for `geotiff`; core users do not.
|
|
22
|
+
|
|
23
|
+
### Usage
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { NVSlide, SlideRenderer } from '@niivue/niivue'
|
|
27
|
+
import { TiffSlideSource } from '@niivue/nv-ext-tile/tiff'
|
|
28
|
+
import { fromBlob, Pool } from 'geotiff'
|
|
29
|
+
|
|
30
|
+
const pool = new Pool() // optional: decode in web workers
|
|
31
|
+
|
|
32
|
+
// Remote file, streamed by HTTP Range
|
|
33
|
+
const source = await TiffSlideSource.fromUrl('https://example.org/slide.svs', { pool })
|
|
34
|
+
// or a local file
|
|
35
|
+
// const source = await TiffSlideSource.fromGeoTIFF(await fromBlob(file), { id: file.name, pool })
|
|
36
|
+
|
|
37
|
+
const slide = NVSlide.fromSource(source)
|
|
38
|
+
// draw with SlideRenderer (WebGL2) / SlideRendererGPU, or nv.setSlidePlane(slide, ...)
|
|
39
|
+
|
|
40
|
+
// When done (NVSlide.dispose() does not reach the source):
|
|
41
|
+
slide.dispose()
|
|
42
|
+
source.dispose()
|
|
43
|
+
pool.destroy()
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`fromUrl` accepts `fetch` to supply your own fetch (e.g. to add auth headers) and `signal` to abort a superseded open. The manifest id defaults to the decoded file name.
|
|
47
|
+
|
|
48
|
+
### What it reads
|
|
49
|
+
|
|
50
|
+
- **Levels:** OME-TIFF pyramids in `SubIFDs` (first plane only), else every tiled image in the IFD chain, one per width, largest first. Stripped images (SVS thumbnail, label, macro) and tiled images off the base aspect ratio (an SVS macro) are skipped.
|
|
51
|
+
- **Pixels:** geotiff's `readRGB` converts RGB, YCbCr, gray, palette and CMYK to 8-bit RGB. Tiles reach `NVSlide` as `raw-rgba`, cropped at the right/bottom edge.
|
|
52
|
+
- **Compression:** none, LZW, JPEG, Deflate, PackBits, LERC, ZSTD, WebP (geotiff's decoders). Not supported: old-style JPEG (6) and JPEG 2000 (Aperio 33003/33005), which geotiff cannot decode.
|
|
53
|
+
- **Limits:** tiles 16-4096 px per side in multiples of 16 (as TIFF 6.0 requires), at most 2^21 tiles across all levels, unsigned integer samples, RGB 8 bits per sample, no WhiteIsZero; other files are rejected. Only the first 64 IFDs (main chain or SubIFDs) are examined.
|
|
54
|
+
- **Decoders trust the compressed stream.** A tile whose JPEG header claims 30000x30000 px can exhaust memory: geotiff offers no bound on decoded size. Only open files you trust, or decode in workers with a `Pool`, so the damage stays in a worker rather than the tab.
|
|
55
|
+
|
|
56
|
+
Not supported yet: 16-bit and multichannel fluorescence planes (only 8-bit RGB/gray displays correctly), choosing an OME plane other than the first, TIFF z-stacks as streamed volumes (`VolumeChunkSource`), pixel spacing.
|
|
57
|
+
|
|
58
|
+
## Local DICOM-WSI folders
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { DicomWsiSource, indexDicomWsi, registerDcm2niixDecoders } from '@niivue/nv-ext-tile/dicom'
|
|
62
|
+
|
|
63
|
+
registerDcm2niixDecoders() // JPEG 2000 and JPEG-LS tiles, and ICC colour management; needs @niivue/dcm2niix
|
|
64
|
+
const files = [...folderInput.files] // <input type="file" webkitdirectory>
|
|
65
|
+
const index = await indexDicomWsi(files)
|
|
66
|
+
const slide = NVSlide.fromSource(new DicomWsiSource(index.slides[0], files))
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- `indexDicomWsi` reads only headers and item tags (`File.slice`), never pixel data.
|
|
70
|
+
It uses the Basic or Extended Offset Table when present, and otherwise walks the items.
|
|
71
|
+
- It handles multi-fragment frames, TILED_SPARSE (holes stay empty), and
|
|
72
|
+
concatenations. TILED_FULL frames keep their implicit order; per-frame
|
|
73
|
+
positions, when given, win (items holding only other metadata, such as Frame
|
|
74
|
+
Content, do not change the order). A file with no DimensionOrganizationType is
|
|
75
|
+
placed by its per-frame positions (path and plane from per-frame values) when
|
|
76
|
+
it gives them, else in the implicit order.
|
|
77
|
+
- Each optical path and focal plane becomes its own slide. A shared
|
|
78
|
+
OpticalPathIdentifier assigns every frame to that path; an identifier missing
|
|
79
|
+
from OpticalPathSequence is its own path. `focalPlane` is a plane index, not µm:
|
|
80
|
+
stated ZOffsets (shared or per frame, to the nm) are ranked across the slide's
|
|
81
|
+
levels (lowest first), so a lower level holding only some planes joins the right
|
|
82
|
+
ones. A multi-plane TILED_FULL level stating no ZOffset keeps its implicit plane
|
|
83
|
+
index (scanners give TotalPixelMatrixOriginSequence no Z), so it is matched by
|
|
84
|
+
index and may join the wrong plane if other levels hold a different set of planes.
|
|
85
|
+
A single shared ZOffset in a multi-plane TILED_FULL file is ignored. ZOffset on
|
|
86
|
+
only some of a file's frames is `malformed`.
|
|
87
|
+
- Files it cannot index are listed in `index.skipped` with a reason: `not-dicom`,
|
|
88
|
+
`transfer-syntax` (explicit VR little endian only), `uncompressed`, `malformed` or
|
|
89
|
+
`not-tiled`.
|
|
90
|
+
- Hostile input is bounded: the header read stops at 64 MiB, sequences at 8 levels,
|
|
91
|
+
a level at 2^22 tiles, a file at 4096 (optical path, focal plane) pairs, TILED_FULL
|
|
92
|
+
frames at tiles x planes x NumberOfOpticalPaths, and counts and offsets are checked
|
|
93
|
+
against the file size (`malformed` otherwise). A slide's levels total at most
|
|
94
|
+
2 x 2^22 tiles; levels past that are dropped and listed in `skipped`.
|
|
95
|
+
- The index holds only per-frame data, bounded by the files' sizes, so a folder of
|
|
96
|
+
many slides indexes in memory proportional to its headers. Each level reports
|
|
97
|
+
`duplicates` and `holes`; its grid-sized tile map is built only when
|
|
98
|
+
a slide is opened (`DicomWsiSource`) or a manifest is written.
|
|
99
|
+
- **ICC colour management.** `DicomWsiSource` and `TiffSlideSource` serve the slide's ICC profile (the optical path's (0028,2000), or TIFF tag 34675). `registerDcm2niixDecoders` also registers @niivue/dcm2niix's LittleCMS as NVSlide's ICC transform (unless the host registered one), so a slide with a profile is shown in sRGB unless `colorManagement` is off. Call it before opening slides: a slide bakes its profile when it is created. This needs @niivue/dcm2niix 1.4.20261105-rc.3 or later (`iccLut`), the peer floor. `fetchIccProfile(signal)` stops waiting on abort; `source.dispose()` cancels the read.
|
|
100
|
+
- `DicomWsiSource` reads one tile at a time and checks each frame's item tag, so a file
|
|
101
|
+
that changed since indexing fails rather than decoding garbage.
|
|
102
|
+
- Supported transfer syntaxes: JPEG baseline/extended (browser), JPEG 2000 and JPEG-LS
|
|
103
|
+
(`registerDcm2niixDecoders`, with `@niivue/dcm2niix` as an optional peer dependency;
|
|
104
|
+
a decoder the host already registered wins).
|
|
105
|
+
- Slides are shown with row 0 of the total pixel matrix at the top (`displayYAxis: 'down'`),
|
|
106
|
+
as OpenSlide shows them.
|
|
107
|
+
- Index speed for the 8 OpenSlide DICOM-WSI test sets (local SSD; Chrome reads through
|
|
108
|
+
the `webkitdirectory` input in the demo):
|
|
109
|
+
|
|
110
|
+
| Set | Frames | Holes | Bun | Chrome |
|
|
111
|
+
|---|---|---|---|---|
|
|
112
|
+
| Hamamatsu-2 (EOT) | 6,199 | 0 | 2 ms | 12 ms |
|
|
113
|
+
| 3DHISTECH-2 (BOT, sparse, split L0) | 56,233 | 449,040 | 65 ms | 102 ms |
|
|
114
|
+
| Leica-4 (sparse) | 8,546 | 0 | 73 ms | 323 ms |
|
|
115
|
+
| 3DHISTECH-1 | 4,434 | 0 | 100 ms | 643 ms |
|
|
116
|
+
| JP2K-33003-1 (item walk) | 4,569 | 0 | 93 ms | 600 ms |
|
|
117
|
+
| CMU-1-JP2K-ICT (item walk) | 24,813 | 0 | 159 ms | 952 ms |
|
|
118
|
+
| CMU-1-JP2K-33005 (item walk) | 28,284 | 0 | 280 ms | 1.74 s |
|
|
119
|
+
| CMU-1-JP2K-RCT (item walk) | 24,813 | 0 | 429 ms | 2.78 s |
|
|
120
|
+
|
|
121
|
+
Indexing awaits `File.slice` reads, so the page stays responsive; a folder without
|
|
122
|
+
offset tables just takes longer in the browser.
|
|
123
|
+
|
|
124
|
+
- Test fixtures: `fixtures/dicom-wsi` (synthetic, about 70 KB, with the generator
|
|
125
|
+
`make_fixtures.py`; `colors.dcm` has real JPEG tiles, made by `cjpeg`, for the demo's
|
|
126
|
+
browser check).
|
|
127
|
+
|
|
128
|
+
## Hosting an indexed slide
|
|
129
|
+
|
|
130
|
+
`manifestFromIndex(slide, files, fileUrlFor)` turns an indexed slide into a `dicom-wsi-range-v1` manifest that NVSlide reads by HTTP Range, as the hosted demo slides are served:
|
|
131
|
+
- Each tile has its offset and length, or `fragments` for a frame split across fragments.
|
|
132
|
+
- Sparse levels list only their present tiles; NVSlide places them by `x`, `y`.
|
|
133
|
+
- A level split across files (a concatenation) names each tile's `fileUrl`.
|
|
134
|
+
|
|
135
|
+
`bun scripts/build-manifest.ts <slide-dir> <id>` indexes `<slide-dir>/files` and writes `<slide-dir>/manifest.json` for the first slide found, with tiles read from `files/<name>` (URL-encoded). Serve the directory with Range and CORS (see below).
|
|
136
|
+
|
|
137
|
+
## Range, CORS and full downloads
|
|
138
|
+
|
|
139
|
+
Reads go through geotiff's 64 KiB block cache: the header and each level's tile offset table cost a few blocks, and a tile costs its compressed size rounded out to block boundaries.
|
|
140
|
+
|
|
141
|
+
A remote server **must** support HTTP Range (`206 Partial Content`). If it answers `200`, `fromUrl` cancels that response and rejects with "server ignored the HTTP Range request"; there is no fallback to downloading the full slide. Cross-origin servers must send `Access-Control-Allow-Origin` and allow the `Range` request header. Exposing `Content-Range` (`Access-Control-Expose-Headers`) is optional: when visible, each response's start offset is checked against the request. For a server you cannot change, download the file and open it with `fromGeoTIFF(await fromBlob(file))`.
|
|
142
|
+
|
|
143
|
+
## Demo
|
|
144
|
+
|
|
145
|
+
[`apps/demo-ext-tile`](../../apps/demo-ext-tile): `bunx nx dev demo-ext-tile`.
|
|
146
|
+
|
|
147
|
+
## Development
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
bunx nx test nv-ext-tile
|
|
151
|
+
bunx nx build nv-ext-tile
|
|
152
|
+
bunx nx typecheck nv-ext-tile
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Tests write small tiled TIFFs in memory (pyramid, SubIFDs, JPEG/YCbCr) and serve them through a fake Range server that records every request.
|
|
156
|
+
|
|
157
|
+
## Part of the [NiiVue](https://github.com/niivue) ecosystem
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register JPEG 2000 and JPEG-LS tile decoders for NVSlide, backed by dcm2niix's
|
|
3
|
+
* WASM worker pool (loaded on the first such tile), and its LittleCMS ICC
|
|
4
|
+
* transform, so slides with a profile are colour-managed. A decoder or ICC
|
|
5
|
+
* transform the host already registered wins. Call it before opening slides.
|
|
6
|
+
* Needs the optional peer dependency `@niivue/dcm2niix`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function registerDcm2niixDecoders(): void;
|
package/dist/dicom.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { registerDcm2niixDecoders } from './dcm2niixDecoders';
|
|
2
|
+
export type { SlideIndex, SlideIndexFrames, SlideIndexInstance, SlideIndexLevel, SlideIndexSkipped, SlideIndexSkipReason, SlideIndexSlide, } from './dicomIndex';
|
|
3
|
+
export { indexDicomWsi } from './dicomIndex';
|
|
4
|
+
export { DicomWsiSource } from './dicomWsiSource';
|
|
5
|
+
export { manifestFromIndex } from './hostedManifest';
|