@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 CHANGED
@@ -1,3 +1,157 @@
1
- # Temporary Holding Version
1
+ # @niivue/nv-ext-tile
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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;
@@ -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';