@knopkem/dicomview 0.1.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/README.md +43 -0
- package/dist/decode-worker.d.ts +1 -0
- package/dist/decode-worker.js +30 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/loader.d.ts +9 -0
- package/dist/loader.js +344 -0
- package/dist/presets.d.ts +9 -0
- package/dist/presets.js +9 -0
- package/dist/types.d.ts +52 -0
- package/dist/types.js +1 -0
- package/dist/viewer.d.ts +23 -0
- package/dist/viewer.js +108 -0
- package/package.json +32 -0
- package/wasm/dicomview_wasm.d.ts +183 -0
- package/wasm/dicomview_wasm.js +1734 -0
- package/wasm/dicomview_wasm_bg.wasm +0 -0
- package/wasm/dicomview_wasm_bg.wasm.d.ts +41 -0
package/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# @dicomview/core
|
|
2
|
+
|
|
3
|
+
`@dicomview/core` is the browser package for `dicomview-rs`. It wraps the Rust/WASM viewer, handles wasm initialization, and provides a DICOMweb loader with optional worker-based decode.
|
|
4
|
+
|
|
5
|
+
## Build
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
`npm run build` performs two steps:
|
|
13
|
+
|
|
14
|
+
1. `wasm-pack build ../crates/dicomview-wasm --target web --out-dir ../../js/wasm`
|
|
15
|
+
2. `tsc -p tsconfig.json`
|
|
16
|
+
|
|
17
|
+
The wasm-pack output is emitted into `js/wasm/`, which is the directory shipped in the npm tarball.
|
|
18
|
+
|
|
19
|
+
## Publish checklist
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install
|
|
23
|
+
npm run build
|
|
24
|
+
npm pack --dry-run
|
|
25
|
+
npm publish
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## Public API
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { DICOMwebLoader, Presets, Viewer } from "@dicomview/core";
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- `Viewer.create(...)` mounts the four-canvas Rust/WebGPU renderer
|
|
36
|
+
- `DICOMwebLoader.loadSeries(...)` streams a DICOMweb series into the viewer
|
|
37
|
+
- `Presets` exposes the built-in CT/MR transfer-function identifiers
|
|
38
|
+
|
|
39
|
+
## Notes
|
|
40
|
+
|
|
41
|
+
- the loader currently expects single-frame instances from DICOMweb
|
|
42
|
+
- worker decode is optional and enabled with `decodeWorkers > 0`
|
|
43
|
+
- rendering requires browser WebGPU support
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import initWasm, { decode_dicom_pixels } from "../wasm/dicomview_wasm.js";
|
|
2
|
+
let wasmReady = null;
|
|
3
|
+
self.addEventListener("message", async (event) => {
|
|
4
|
+
const message = event.data;
|
|
5
|
+
if (message.type !== "decode") {
|
|
6
|
+
return;
|
|
7
|
+
}
|
|
8
|
+
try {
|
|
9
|
+
if (!wasmReady) {
|
|
10
|
+
wasmReady = initWasm(message.wasmUrl);
|
|
11
|
+
}
|
|
12
|
+
await wasmReady;
|
|
13
|
+
const pixels = decode_dicom_pixels(new Uint8Array(message.bytes));
|
|
14
|
+
const response = {
|
|
15
|
+
type: "decoded",
|
|
16
|
+
jobId: message.jobId,
|
|
17
|
+
sliceIndex: message.sliceIndex,
|
|
18
|
+
pixels,
|
|
19
|
+
};
|
|
20
|
+
self.postMessage(response, [pixels.buffer]);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const response = {
|
|
24
|
+
type: "error",
|
|
25
|
+
jobId: message.jobId,
|
|
26
|
+
message: error instanceof Error ? error.message : String(error),
|
|
27
|
+
};
|
|
28
|
+
self.postMessage(response);
|
|
29
|
+
}
|
|
30
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { DICOMwebLoader } from "./loader.js";
|
|
2
|
+
export { Presets } from "./presets.js";
|
|
3
|
+
export { ensureDicomviewWasm, Viewer } from "./viewer.js";
|
|
4
|
+
export type { BlendMode, DICOMwebLoaderOptions, ProgressCallback, ProjectionMode, SeriesParams, ThickSlabOptions, ViewportId, ViewerOptions, VolumeGeometry, VolumePreset, WasmSource, } from "./types.js";
|
package/dist/index.js
ADDED
package/dist/loader.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Viewer } from "./viewer.js";
|
|
2
|
+
import type { DICOMwebLoaderOptions, ProgressCallback, SeriesParams } from "./types.js";
|
|
3
|
+
export declare class DICOMwebLoader {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(options: DICOMwebLoaderOptions);
|
|
6
|
+
onProgress(callback: ProgressCallback): void;
|
|
7
|
+
abort(): void;
|
|
8
|
+
loadSeries(viewer: Viewer, params: SeriesParams): Promise<void>;
|
|
9
|
+
}
|
package/dist/loader.js
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
export class DICOMwebLoader {
|
|
2
|
+
#options;
|
|
3
|
+
#progressCallback = null;
|
|
4
|
+
#abortController = null;
|
|
5
|
+
constructor(options) {
|
|
6
|
+
this.#options = {
|
|
7
|
+
...options,
|
|
8
|
+
concurrency: Math.max(1, options.concurrency ?? 4),
|
|
9
|
+
decodeWorkers: Math.max(0, options.decodeWorkers ?? 0),
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
onProgress(callback) {
|
|
13
|
+
this.#progressCallback = callback;
|
|
14
|
+
}
|
|
15
|
+
abort() {
|
|
16
|
+
this.#abortController?.abort();
|
|
17
|
+
this.#abortController = null;
|
|
18
|
+
}
|
|
19
|
+
async loadSeries(viewer, params) {
|
|
20
|
+
const controller = new AbortController();
|
|
21
|
+
this.#abortController = controller;
|
|
22
|
+
const metadataUrl = [
|
|
23
|
+
trimRoot(this.#options.wadoRoot),
|
|
24
|
+
"studies",
|
|
25
|
+
encodeURIComponent(params.studyUid),
|
|
26
|
+
"series",
|
|
27
|
+
encodeURIComponent(params.seriesUid),
|
|
28
|
+
"metadata",
|
|
29
|
+
].join("/");
|
|
30
|
+
const metadataJson = (await this.#fetchJson(metadataUrl, controller.signal));
|
|
31
|
+
const instances = parseSeriesMetadata(metadataJson);
|
|
32
|
+
const geometry = deriveGeometry(instances);
|
|
33
|
+
viewer.prepareVolume(geometry);
|
|
34
|
+
const pool = this.#createDecodeWorkerPool();
|
|
35
|
+
const total = instances.length;
|
|
36
|
+
let loaded = 0;
|
|
37
|
+
let nextIndex = 0;
|
|
38
|
+
let renderScheduled = false;
|
|
39
|
+
const scheduleRender = () => {
|
|
40
|
+
if (renderScheduled) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
renderScheduled = true;
|
|
44
|
+
const requestFrame = typeof requestAnimationFrame === "function"
|
|
45
|
+
? requestAnimationFrame
|
|
46
|
+
: (callback) => {
|
|
47
|
+
callback(0);
|
|
48
|
+
return 0;
|
|
49
|
+
};
|
|
50
|
+
requestFrame(() => {
|
|
51
|
+
renderScheduled = false;
|
|
52
|
+
viewer.render();
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
try {
|
|
56
|
+
const workers = Array.from({ length: Math.min(this.#options.concurrency ?? 1, total) }, async () => {
|
|
57
|
+
while (true) {
|
|
58
|
+
const sliceIndex = nextIndex;
|
|
59
|
+
nextIndex += 1;
|
|
60
|
+
if (sliceIndex >= total) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const instance = instances[sliceIndex];
|
|
64
|
+
const instanceUrl = [
|
|
65
|
+
trimRoot(this.#options.wadoRoot),
|
|
66
|
+
"studies",
|
|
67
|
+
encodeURIComponent(params.studyUid),
|
|
68
|
+
"series",
|
|
69
|
+
encodeURIComponent(params.seriesUid),
|
|
70
|
+
"instances",
|
|
71
|
+
encodeURIComponent(instance.sopInstanceUid),
|
|
72
|
+
].join("/");
|
|
73
|
+
const bytes = await this.#fetchBytes(instanceUrl, controller.signal);
|
|
74
|
+
if (pool) {
|
|
75
|
+
const decoded = await pool.decode(sliceIndex, bytes);
|
|
76
|
+
viewer.feedPixelSlice(decoded.sliceIndex, decoded.pixels);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
viewer.feedDicomSlice(sliceIndex, bytes);
|
|
80
|
+
}
|
|
81
|
+
loaded += 1;
|
|
82
|
+
this.#progressCallback?.(loaded, total);
|
|
83
|
+
scheduleRender();
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
await Promise.all(workers);
|
|
87
|
+
viewer.render();
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
pool?.destroy();
|
|
91
|
+
if (this.#abortController === controller) {
|
|
92
|
+
this.#abortController = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
async #fetchJson(url, signal) {
|
|
97
|
+
const response = await this.#fetch(url, signal, "application/dicom+json");
|
|
98
|
+
return response.json();
|
|
99
|
+
}
|
|
100
|
+
async #fetchBytes(url, signal) {
|
|
101
|
+
const response = await this.#fetch(url, signal, "application/dicom; transfer-syntax=*");
|
|
102
|
+
return new Uint8Array(await response.arrayBuffer());
|
|
103
|
+
}
|
|
104
|
+
async #fetch(url, signal, accept) {
|
|
105
|
+
const fetchImpl = this.#options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
106
|
+
const headers = new Headers(this.#options.headers);
|
|
107
|
+
headers.set("Accept", accept);
|
|
108
|
+
const response = await fetchImpl(url, {
|
|
109
|
+
method: "GET",
|
|
110
|
+
headers,
|
|
111
|
+
signal,
|
|
112
|
+
});
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
throw new Error(`HTTP ${response.status} while fetching ${url}`);
|
|
115
|
+
}
|
|
116
|
+
return response;
|
|
117
|
+
}
|
|
118
|
+
#createDecodeWorkerPool() {
|
|
119
|
+
const requested = this.#options.decodeWorkers ?? 0;
|
|
120
|
+
if (requested <= 0 || typeof Worker === "undefined") {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
const wasmUrl = normalizeWorkerWasmUrl(this.#options.wasmUrl);
|
|
124
|
+
return new DecodeWorkerPool(requested, wasmUrl);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
class DecodeWorkerPool {
|
|
128
|
+
#workers;
|
|
129
|
+
#pending = new Map();
|
|
130
|
+
#workerUrl = new URL("./decode-worker.js", import.meta.url);
|
|
131
|
+
#nextWorkerIndex = 0;
|
|
132
|
+
#nextJobId = 1;
|
|
133
|
+
constructor(size, wasmUrl) {
|
|
134
|
+
this.#workers = Array.from({ length: size }, () => {
|
|
135
|
+
const worker = new Worker(this.#workerUrl, { type: "module" });
|
|
136
|
+
worker.addEventListener("message", (event) => {
|
|
137
|
+
const message = event.data;
|
|
138
|
+
const pending = this.#pending.get(message.jobId);
|
|
139
|
+
if (!pending) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.#pending.delete(message.jobId);
|
|
143
|
+
if (message.type === "decoded") {
|
|
144
|
+
pending.resolve({
|
|
145
|
+
sliceIndex: message.sliceIndex,
|
|
146
|
+
pixels: message.pixels,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
pending.reject(new Error(message.message));
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
worker.addEventListener("error", (event) => {
|
|
154
|
+
for (const [jobId, pending] of this.#pending) {
|
|
155
|
+
this.#pending.delete(jobId);
|
|
156
|
+
pending.reject(event.error ?? new Error(event.message));
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
worker.__dicomviewWasmUrl = wasmUrl;
|
|
160
|
+
return worker;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
decode(sliceIndex, bytes) {
|
|
164
|
+
const worker = this.#workers[this.#nextWorkerIndex];
|
|
165
|
+
this.#nextWorkerIndex = (this.#nextWorkerIndex + 1) % this.#workers.length;
|
|
166
|
+
const jobId = this.#nextJobId;
|
|
167
|
+
this.#nextJobId += 1;
|
|
168
|
+
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
169
|
+
const message = {
|
|
170
|
+
type: "decode",
|
|
171
|
+
jobId,
|
|
172
|
+
sliceIndex,
|
|
173
|
+
wasmUrl: worker.__dicomviewWasmUrl,
|
|
174
|
+
bytes: buffer,
|
|
175
|
+
};
|
|
176
|
+
worker.postMessage(message, [buffer]);
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
this.#pending.set(jobId, { resolve, reject });
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
destroy() {
|
|
182
|
+
for (const worker of this.#workers) {
|
|
183
|
+
worker.terminate();
|
|
184
|
+
}
|
|
185
|
+
this.#workers.length = 0;
|
|
186
|
+
this.#pending.clear();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function parseSeriesMetadata(metadata) {
|
|
190
|
+
const instances = metadata.map(parseInstanceMetadata);
|
|
191
|
+
instances.sort(compareInstances);
|
|
192
|
+
return instances;
|
|
193
|
+
}
|
|
194
|
+
function parseInstanceMetadata(instance) {
|
|
195
|
+
const sopInstanceUid = firstString(instance, "00080018");
|
|
196
|
+
if (!sopInstanceUid) {
|
|
197
|
+
throw new Error("DICOMweb metadata is missing SOP Instance UID");
|
|
198
|
+
}
|
|
199
|
+
const rows = firstNumber(instance, "00280010");
|
|
200
|
+
const columns = firstNumber(instance, "00280011");
|
|
201
|
+
if (rows === undefined || columns === undefined) {
|
|
202
|
+
throw new Error("DICOMweb metadata is missing image dimensions");
|
|
203
|
+
}
|
|
204
|
+
const orientation = firstNumberArray(instance, "00200037", 6);
|
|
205
|
+
return {
|
|
206
|
+
sopInstanceUid,
|
|
207
|
+
instanceNumber: firstNumber(instance, "00200013") ?? 0,
|
|
208
|
+
rows,
|
|
209
|
+
columns,
|
|
210
|
+
pixelSpacing: pairNumberArray(instance, "00280030"),
|
|
211
|
+
sliceThickness: firstNumber(instance, "00180050"),
|
|
212
|
+
imagePosition: tripletNumberArray(instance, "00200032"),
|
|
213
|
+
imageOrientation: orientation
|
|
214
|
+
? [
|
|
215
|
+
[orientation[0], orientation[1], orientation[2]],
|
|
216
|
+
[orientation[3], orientation[4], orientation[5]],
|
|
217
|
+
]
|
|
218
|
+
: undefined,
|
|
219
|
+
numberOfFrames: firstNumber(instance, "00280008") ?? 1,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function deriveGeometry(instances) {
|
|
223
|
+
if (instances.length === 0) {
|
|
224
|
+
throw new Error("Series metadata is empty");
|
|
225
|
+
}
|
|
226
|
+
if (instances.some((instance) => instance.numberOfFrames !== 1)) {
|
|
227
|
+
throw new Error("Multi-frame DICOMweb metadata is not yet supported");
|
|
228
|
+
}
|
|
229
|
+
const first = instances[0];
|
|
230
|
+
if (instances.some((instance) => instance.rows !== first.rows || instance.columns !== first.columns)) {
|
|
231
|
+
throw new Error("Series contains inconsistent frame dimensions");
|
|
232
|
+
}
|
|
233
|
+
const rowDirection = first.imageOrientation?.[0] ?? [1, 0, 0];
|
|
234
|
+
const columnDirection = first.imageOrientation?.[1] ?? [0, 1, 0];
|
|
235
|
+
const normal = normalize(cross(rowDirection, columnDirection));
|
|
236
|
+
const pixelSpacing = first.pixelSpacing ?? [1, 1];
|
|
237
|
+
const sliceSpacing = projectedSliceSpacing(instances) ?? first.sliceThickness ?? 1;
|
|
238
|
+
return {
|
|
239
|
+
dimensions: [first.columns, first.rows, instances.length],
|
|
240
|
+
spacing: [pixelSpacing[1], pixelSpacing[0], sliceSpacing],
|
|
241
|
+
origin: first.imagePosition ?? [0, 0, 0],
|
|
242
|
+
direction: [rowDirection, columnDirection, normal],
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
function projectedSliceSpacing(instances) {
|
|
246
|
+
if (instances.length < 2) {
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
const row = instances[0].imageOrientation?.[0];
|
|
250
|
+
const col = instances[0].imageOrientation?.[1];
|
|
251
|
+
const first = instances[0].imagePosition;
|
|
252
|
+
const second = instances[1].imagePosition;
|
|
253
|
+
if (!row || !col || !first || !second) {
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
const normal = normalize(cross(row, col));
|
|
257
|
+
return Math.abs(dot(normal, subtract(second, first)));
|
|
258
|
+
}
|
|
259
|
+
function compareInstances(left, right) {
|
|
260
|
+
const row = left.imageOrientation?.[0];
|
|
261
|
+
const col = left.imageOrientation?.[1];
|
|
262
|
+
if (left.imagePosition && right.imagePosition && row && col) {
|
|
263
|
+
const normal = normalize(cross(row, col));
|
|
264
|
+
const leftDistance = dot(normal, left.imagePosition);
|
|
265
|
+
const rightDistance = dot(normal, right.imagePosition);
|
|
266
|
+
return leftDistance - rightDistance;
|
|
267
|
+
}
|
|
268
|
+
return (left.instanceNumber - right.instanceNumber ||
|
|
269
|
+
left.sopInstanceUid.localeCompare(right.sopInstanceUid));
|
|
270
|
+
}
|
|
271
|
+
function tripletNumberArray(instance, tag) {
|
|
272
|
+
const values = firstNumberArray(instance, tag, 3);
|
|
273
|
+
return values ? [values[0], values[1], values[2]] : undefined;
|
|
274
|
+
}
|
|
275
|
+
function pairNumberArray(instance, tag) {
|
|
276
|
+
const values = firstNumberArray(instance, tag, 2);
|
|
277
|
+
return values ? [values[0], values[1]] : undefined;
|
|
278
|
+
}
|
|
279
|
+
function firstNumberArray(instance, tag, length) {
|
|
280
|
+
const values = instance[tag]?.Value;
|
|
281
|
+
if (!values || values.length < length) {
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
return values.slice(0, length).map((value) => {
|
|
285
|
+
if (typeof value === "number") {
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
const parsed = Number.parseFloat(value);
|
|
289
|
+
if (!Number.isFinite(parsed)) {
|
|
290
|
+
throw new Error(`Tag ${tag} contains a non-numeric value`);
|
|
291
|
+
}
|
|
292
|
+
return parsed;
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
function firstNumber(instance, tag) {
|
|
296
|
+
const value = instance[tag]?.Value?.[0];
|
|
297
|
+
if (value === undefined) {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
if (typeof value === "number") {
|
|
301
|
+
return value;
|
|
302
|
+
}
|
|
303
|
+
const parsed = Number.parseFloat(value);
|
|
304
|
+
if (!Number.isFinite(parsed)) {
|
|
305
|
+
throw new Error(`Tag ${tag} contains a non-numeric value`);
|
|
306
|
+
}
|
|
307
|
+
return parsed;
|
|
308
|
+
}
|
|
309
|
+
function firstString(instance, tag) {
|
|
310
|
+
const value = instance[tag]?.Value?.[0];
|
|
311
|
+
return typeof value === "string" ? value : undefined;
|
|
312
|
+
}
|
|
313
|
+
function trimRoot(root) {
|
|
314
|
+
return root.replace(/\/+$/, "");
|
|
315
|
+
}
|
|
316
|
+
function normalizeWorkerWasmUrl(wasmUrl) {
|
|
317
|
+
if (typeof wasmUrl === "string") {
|
|
318
|
+
return wasmUrl;
|
|
319
|
+
}
|
|
320
|
+
if (wasmUrl instanceof URL) {
|
|
321
|
+
return wasmUrl.toString();
|
|
322
|
+
}
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
function subtract(left, right) {
|
|
326
|
+
return [left[0] - right[0], left[1] - right[1], left[2] - right[2]];
|
|
327
|
+
}
|
|
328
|
+
function cross(left, right) {
|
|
329
|
+
return [
|
|
330
|
+
left[1] * right[2] - left[2] * right[1],
|
|
331
|
+
left[2] * right[0] - left[0] * right[2],
|
|
332
|
+
left[0] * right[1] - left[1] * right[0],
|
|
333
|
+
];
|
|
334
|
+
}
|
|
335
|
+
function dot(left, right) {
|
|
336
|
+
return left[0] * right[0] + left[1] * right[1] + left[2] * right[2];
|
|
337
|
+
}
|
|
338
|
+
function normalize(vector) {
|
|
339
|
+
const length = Math.hypot(vector[0], vector[1], vector[2]);
|
|
340
|
+
if (length === 0) {
|
|
341
|
+
return [0, 0, 1];
|
|
342
|
+
}
|
|
343
|
+
return [vector[0] / length, vector[1] / length, vector[2] / length];
|
|
344
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export declare const Presets: {
|
|
2
|
+
readonly CT_BONE: "ct-bone";
|
|
3
|
+
readonly CT_SOFT_TISSUE: "ct-soft-tissue";
|
|
4
|
+
readonly CT_LUNG: "ct-lung";
|
|
5
|
+
readonly CT_MIP: "ct-mip";
|
|
6
|
+
readonly MR_DEFAULT: "mr-default";
|
|
7
|
+
readonly MR_ANGIO: "mr-angio";
|
|
8
|
+
readonly MR_T2_BRAIN: "mr-t2-brain";
|
|
9
|
+
};
|
package/dist/presets.js
ADDED
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export type ViewportId = "axial" | "coronal" | "sagittal";
|
|
2
|
+
export type BlendMode = "composite" | "mip" | "minip" | "average";
|
|
3
|
+
export type ProjectionMode = "thin" | "mip" | "minip" | "average";
|
|
4
|
+
export type VolumePreset = "ct-bone" | "ct-soft-tissue" | "ct-lung" | "ct-mip" | "mr-default" | "mr-angio" | "mr-t2-brain";
|
|
5
|
+
export type WasmSource = string | URL | Request | Response;
|
|
6
|
+
export interface VolumeGeometry {
|
|
7
|
+
dimensions: [number, number, number];
|
|
8
|
+
spacing: [number, number, number];
|
|
9
|
+
origin: [number, number, number];
|
|
10
|
+
direction: [
|
|
11
|
+
[
|
|
12
|
+
number,
|
|
13
|
+
number,
|
|
14
|
+
number
|
|
15
|
+
],
|
|
16
|
+
[
|
|
17
|
+
number,
|
|
18
|
+
number,
|
|
19
|
+
number
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
number,
|
|
23
|
+
number,
|
|
24
|
+
number
|
|
25
|
+
]
|
|
26
|
+
];
|
|
27
|
+
}
|
|
28
|
+
export interface ViewerOptions {
|
|
29
|
+
axial: HTMLCanvasElement;
|
|
30
|
+
coronal: HTMLCanvasElement;
|
|
31
|
+
sagittal: HTMLCanvasElement;
|
|
32
|
+
volume: HTMLCanvasElement;
|
|
33
|
+
wasmUrl?: WasmSource;
|
|
34
|
+
}
|
|
35
|
+
export interface ThickSlabOptions {
|
|
36
|
+
viewport: ViewportId;
|
|
37
|
+
thickness: number;
|
|
38
|
+
projection: ProjectionMode;
|
|
39
|
+
}
|
|
40
|
+
export interface SeriesParams {
|
|
41
|
+
studyUid: string;
|
|
42
|
+
seriesUid: string;
|
|
43
|
+
}
|
|
44
|
+
export type ProgressCallback = (loaded: number, total: number) => void;
|
|
45
|
+
export interface DICOMwebLoaderOptions {
|
|
46
|
+
wadoRoot: string;
|
|
47
|
+
fetch?: typeof fetch;
|
|
48
|
+
headers?: HeadersInit;
|
|
49
|
+
concurrency?: number;
|
|
50
|
+
decodeWorkers?: number;
|
|
51
|
+
wasmUrl?: WasmSource;
|
|
52
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/viewer.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { BlendMode, ThickSlabOptions, ViewerOptions, ViewportId, VolumeGeometry, VolumePreset, WasmSource } from "./types.js";
|
|
2
|
+
export declare function ensureDicomviewWasm(wasmUrl?: WasmSource): Promise<void>;
|
|
3
|
+
export declare class Viewer {
|
|
4
|
+
#private;
|
|
5
|
+
private constructor();
|
|
6
|
+
static create(options: ViewerOptions): Promise<Viewer>;
|
|
7
|
+
get loadingProgress(): number;
|
|
8
|
+
prepareVolume(geometry: VolumeGeometry): void;
|
|
9
|
+
feedDicomSlice(zIndex: number, bytes: ArrayBuffer | ArrayBufferView): void;
|
|
10
|
+
feedPixelSlice(zIndex: number, pixels: Int16Array | ArrayBuffer): void;
|
|
11
|
+
render(): void;
|
|
12
|
+
setCrosshair(x: number, y: number, z: number): void;
|
|
13
|
+
scrollSlice(viewport: ViewportId, delta: number): void;
|
|
14
|
+
setWindowLevel(center: number, width: number): void;
|
|
15
|
+
orbit(dx: number, dy: number): void;
|
|
16
|
+
pan(dx: number, dy: number): void;
|
|
17
|
+
zoom(factor: number): void;
|
|
18
|
+
setBlendMode(mode: BlendMode): void;
|
|
19
|
+
setThickSlab(options: ThickSlabOptions): void;
|
|
20
|
+
setVolumePreset(preset: VolumePreset): void;
|
|
21
|
+
reset(): void;
|
|
22
|
+
destroy(): void;
|
|
23
|
+
}
|
package/dist/viewer.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import initWasm, { Viewer as WasmViewer } from "../wasm/dicomview_wasm.js";
|
|
2
|
+
const VIEWPORT_CODE = {
|
|
3
|
+
axial: 0,
|
|
4
|
+
coronal: 1,
|
|
5
|
+
sagittal: 2,
|
|
6
|
+
};
|
|
7
|
+
const BLEND_MODE_CODE = {
|
|
8
|
+
composite: 0,
|
|
9
|
+
mip: 1,
|
|
10
|
+
minip: 2,
|
|
11
|
+
average: 3,
|
|
12
|
+
};
|
|
13
|
+
const PROJECTION_CODE = {
|
|
14
|
+
thin: 0,
|
|
15
|
+
mip: 1,
|
|
16
|
+
minip: 2,
|
|
17
|
+
average: 3,
|
|
18
|
+
};
|
|
19
|
+
let wasmInitPromise = null;
|
|
20
|
+
export async function ensureDicomviewWasm(wasmUrl) {
|
|
21
|
+
if (!wasmInitPromise) {
|
|
22
|
+
wasmInitPromise = initWasm(wasmUrl);
|
|
23
|
+
}
|
|
24
|
+
await wasmInitPromise;
|
|
25
|
+
}
|
|
26
|
+
export class Viewer {
|
|
27
|
+
#inner;
|
|
28
|
+
constructor(inner) {
|
|
29
|
+
this.#inner = inner;
|
|
30
|
+
}
|
|
31
|
+
static async create(options) {
|
|
32
|
+
await ensureDicomviewWasm(options.wasmUrl);
|
|
33
|
+
const inner = await WasmViewer.create({
|
|
34
|
+
axial: options.axial,
|
|
35
|
+
coronal: options.coronal,
|
|
36
|
+
sagittal: options.sagittal,
|
|
37
|
+
volume: options.volume,
|
|
38
|
+
});
|
|
39
|
+
return new Viewer(inner);
|
|
40
|
+
}
|
|
41
|
+
get loadingProgress() {
|
|
42
|
+
return this.#requireInner().loading_progress();
|
|
43
|
+
}
|
|
44
|
+
prepareVolume(geometry) {
|
|
45
|
+
this.#requireInner().prepare_volume(geometry);
|
|
46
|
+
}
|
|
47
|
+
feedDicomSlice(zIndex, bytes) {
|
|
48
|
+
this.#requireInner().feed_dicom_slice(zIndex, toUint8Array(bytes));
|
|
49
|
+
}
|
|
50
|
+
feedPixelSlice(zIndex, pixels) {
|
|
51
|
+
const data = pixels instanceof Int16Array ? pixels : new Int16Array(pixels);
|
|
52
|
+
this.#requireInner().feed_pixel_slice(zIndex, data);
|
|
53
|
+
}
|
|
54
|
+
render() {
|
|
55
|
+
this.#requireInner().render();
|
|
56
|
+
}
|
|
57
|
+
setCrosshair(x, y, z) {
|
|
58
|
+
this.#requireInner().set_crosshair(x, y, z);
|
|
59
|
+
}
|
|
60
|
+
scrollSlice(viewport, delta) {
|
|
61
|
+
this.#requireInner().scroll_slice(VIEWPORT_CODE[viewport], delta);
|
|
62
|
+
}
|
|
63
|
+
setWindowLevel(center, width) {
|
|
64
|
+
this.#requireInner().set_window_level(center, width);
|
|
65
|
+
}
|
|
66
|
+
orbit(dx, dy) {
|
|
67
|
+
this.#requireInner().orbit(dx, dy);
|
|
68
|
+
}
|
|
69
|
+
pan(dx, dy) {
|
|
70
|
+
this.#requireInner().pan(dx, dy);
|
|
71
|
+
}
|
|
72
|
+
zoom(factor) {
|
|
73
|
+
this.#requireInner().zoom(factor);
|
|
74
|
+
}
|
|
75
|
+
setBlendMode(mode) {
|
|
76
|
+
this.#requireInner().set_blend_mode(BLEND_MODE_CODE[mode]);
|
|
77
|
+
}
|
|
78
|
+
setThickSlab(options) {
|
|
79
|
+
this.#requireInner().set_thick_slab(VIEWPORT_CODE[options.viewport], options.thickness, PROJECTION_CODE[options.projection]);
|
|
80
|
+
}
|
|
81
|
+
setVolumePreset(preset) {
|
|
82
|
+
this.#requireInner().set_volume_preset(preset);
|
|
83
|
+
}
|
|
84
|
+
reset() {
|
|
85
|
+
this.#requireInner().reset();
|
|
86
|
+
}
|
|
87
|
+
destroy() {
|
|
88
|
+
if (this.#inner) {
|
|
89
|
+
this.#inner.destroy();
|
|
90
|
+
this.#inner = null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
#requireInner() {
|
|
94
|
+
if (!this.#inner) {
|
|
95
|
+
throw new Error("Viewer has already been destroyed");
|
|
96
|
+
}
|
|
97
|
+
return this.#inner;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function toUint8Array(value) {
|
|
101
|
+
if (value instanceof Uint8Array) {
|
|
102
|
+
return value;
|
|
103
|
+
}
|
|
104
|
+
if (ArrayBuffer.isView(value)) {
|
|
105
|
+
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
|
|
106
|
+
}
|
|
107
|
+
return new Uint8Array(value);
|
|
108
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@knopkem/dicomview",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Rust/WASM medical imaging primitives for the web",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"wasm",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build:wasm": "wasm-pack build ../crates/dicomview-wasm --target web --out-dir ../../js/wasm --out-name dicomview_wasm && rm -f ./wasm/.gitignore ./wasm/package.json",
|
|
20
|
+
"build:ts": "tsc -p tsconfig.json",
|
|
21
|
+
"build": "npm run build:wasm && npm run build:ts",
|
|
22
|
+
"prepack": "npm run build",
|
|
23
|
+
"test": "tsc -p tsconfig.json --noEmit"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT OR Apache-2.0",
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"typescript": "^5.6.3"
|
|
31
|
+
}
|
|
32
|
+
}
|