nuxt-cornerstone 1.0.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/LICENSE +21 -0
- package/README.md +933 -0
- package/dist/module.d.mts +22 -0
- package/dist/module.json +12 -0
- package/dist/module.mjs +168 -0
- package/dist/runtime/annotation-json.d.ts +76 -0
- package/dist/runtime/annotation-json.js +176 -0
- package/dist/runtime/components/CornerstoneViewport.d.vue.ts +57 -0
- package/dist/runtime/components/CornerstoneViewport.vue +227 -0
- package/dist/runtime/components/CornerstoneViewport.vue.d.ts +57 -0
- package/dist/runtime/composables/useAnnotationReport.d.ts +28 -0
- package/dist/runtime/composables/useAnnotationReport.js +66 -0
- package/dist/runtime/composables/useCinePlayer.d.ts +52 -0
- package/dist/runtime/composables/useCinePlayer.js +95 -0
- package/dist/runtime/composables/useCornerstone.d.ts +17 -0
- package/dist/runtime/composables/useCornerstone.js +30 -0
- package/dist/runtime/composables/useCornerstoneI18n.d.ts +24 -0
- package/dist/runtime/composables/useCornerstoneI18n.js +32 -0
- package/dist/runtime/composables/useCornerstoneTools.d.ts +21 -0
- package/dist/runtime/composables/useCornerstoneTools.js +105 -0
- package/dist/runtime/composables/useDicomAnnotations.d.ts +76 -0
- package/dist/runtime/composables/useDicomAnnotations.js +220 -0
- package/dist/runtime/composables/useDicomFiles.d.ts +87 -0
- package/dist/runtime/composables/useDicomFiles.js +234 -0
- package/dist/runtime/composables/useDicomGuard.d.ts +24 -0
- package/dist/runtime/composables/useDicomGuard.js +30 -0
- package/dist/runtime/composables/useDicomStudy.d.ts +122 -0
- package/dist/runtime/composables/useDicomStudy.js +222 -0
- package/dist/runtime/composables/useImagePrefetch.d.ts +78 -0
- package/dist/runtime/composables/useImagePrefetch.js +119 -0
- package/dist/runtime/composables/useMeasurements.d.ts +51 -0
- package/dist/runtime/composables/useMeasurements.js +138 -0
- package/dist/runtime/composables/useRenderingEngine.d.ts +6 -0
- package/dist/runtime/composables/useRenderingEngine.js +46 -0
- package/dist/runtime/composables/useStackCine.d.ts +50 -0
- package/dist/runtime/composables/useStackCine.js +62 -0
- package/dist/runtime/composables/useViewerShortcuts.d.ts +94 -0
- package/dist/runtime/composables/useViewerShortcuts.js +115 -0
- package/dist/runtime/cornerstone.d.ts +21 -0
- package/dist/runtime/cornerstone.js +100 -0
- package/dist/runtime/dicom-instances.d.ts +32 -0
- package/dist/runtime/dicom-instances.js +15 -0
- package/dist/runtime/dicom-zip.d.ts +95 -0
- package/dist/runtime/dicom-zip.js +149 -0
- package/dist/runtime/i18n/detect.d.ts +37 -0
- package/dist/runtime/i18n/detect.js +37 -0
- package/dist/runtime/i18n/index.d.ts +61 -0
- package/dist/runtime/i18n/index.js +145 -0
- package/dist/runtime/i18n/messages.d.ts +122 -0
- package/dist/runtime/i18n/messages.js +147 -0
- package/dist/runtime/plugin.client.d.ts +13 -0
- package/dist/runtime/plugin.client.js +31 -0
- package/dist/runtime/plugin.i18n.d.ts +12 -0
- package/dist/runtime/plugin.i18n.js +15 -0
- package/dist/runtime/types.d.ts +150 -0
- package/dist/runtime/types.js +10 -0
- package/dist/types.d.mts +29 -0
- package/package.json +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
1
|
+
# nuxt-cornerstone
|
|
2
|
+
|
|
3
|
+
[Cornerstone3D](https://www.cornerstonejs.org/) for Nuxt 4 — a DICOM stack viewport, tool groups
|
|
4
|
+
and loaders, with the build configuration Cornerstone needs already done.
|
|
5
|
+
|
|
6
|
+
Cornerstone3D cannot simply be imported into a Nuxt app. It needs Vite configured a particular way
|
|
7
|
+
(module workers, dependency prebundling held back from the WASM codecs, CommonJS interop), and it is
|
|
8
|
+
strictly browser-only, so SSR must never touch it. A plugin file cannot change Vite's configuration
|
|
9
|
+
for the app that installs it — a module can, which is what this is.
|
|
10
|
+
|
|
11
|
+
Built and verified against **Cornerstone3D 5.10.7**, **Nuxt 4.5.2**, **Vite 8.3.0**.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
The `@cornerstonejs/*` packages are peer dependencies, so your app owns exactly one copy of each.
|
|
16
|
+
Two copies of `@cornerstonejs/core` in a dependency tree means two image caches and two event
|
|
17
|
+
targets, and it fails in ways that are hard to trace.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pnpm add nuxt-cornerstone
|
|
21
|
+
pnpm add @cornerstonejs/core @cornerstonejs/tools @cornerstonejs/dicom-image-loader \
|
|
22
|
+
@cornerstonejs/metadata @cornerstonejs/utils dicom-parser
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
`@cornerstonejs/metadata` and `@cornerstonejs/utils` were split out of `core` in Cornerstone3D 5 and
|
|
26
|
+
are exact-pinned peers of it. Nothing in your code imports them directly, but the install is broken
|
|
27
|
+
without them. All `@cornerstonejs` packages must be on the same version.
|
|
28
|
+
|
|
29
|
+
The module checks for all six at startup and fails with the install command if any is missing.
|
|
30
|
+
|
|
31
|
+
### Styling
|
|
32
|
+
|
|
33
|
+
Nothing to configure. `<CornerstoneViewport>` brings the handful of rules it needs to function —
|
|
34
|
+
a size of its own, `overflow: hidden`, `touch-action: none`, and `display: block` on the canvas
|
|
35
|
+
Cornerstone appends to it — in its own `<style>` block, which your build compiles like any other
|
|
36
|
+
component's. There is no Tailwind requirement and no source-scanning line to add.
|
|
37
|
+
|
|
38
|
+
Those rules are written as `:where(.nuxt-cornerstone-viewport)`, which contributes no specificity,
|
|
39
|
+
so anything you write beats them without `!important`:
|
|
40
|
+
|
|
41
|
+
```vue
|
|
42
|
+
<CornerstoneViewport :image-ids="imageIds" class="h-[600px] rounded-lg" />
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The element must end up with a height from somewhere. It defaults to `100%`, which means a parent
|
|
46
|
+
with a height of its own; a viewport that reports no error and draws nothing is almost always an
|
|
47
|
+
ancestor that collapsed to zero.
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// nuxt.config.ts
|
|
51
|
+
export default defineNuxtConfig({
|
|
52
|
+
modules: ['nuxt-cornerstone'],
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
```vue
|
|
59
|
+
<script setup lang="ts">
|
|
60
|
+
const { addFiles } = useDicomFiles()
|
|
61
|
+
const tools = useCornerstoneTools()
|
|
62
|
+
|
|
63
|
+
const imageIds = ref<string[]>([])
|
|
64
|
+
const imageIndex = ref(0)
|
|
65
|
+
|
|
66
|
+
async function open(files: FileList | null) {
|
|
67
|
+
if (files) imageIds.value = await addFiles(files)
|
|
68
|
+
}
|
|
69
|
+
</script>
|
|
70
|
+
|
|
71
|
+
<template>
|
|
72
|
+
<input type="file" multiple @change="open(($event.target as HTMLInputElement).files)">
|
|
73
|
+
<button @click="tools.setActive('LengthTool')">Measure</button>
|
|
74
|
+
|
|
75
|
+
<div style="height: 600px">
|
|
76
|
+
<CornerstoneViewport
|
|
77
|
+
:image-ids="imageIds"
|
|
78
|
+
:image-index="imageIndex"
|
|
79
|
+
@image-index-change="imageIndex = $event"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Out of the box: left-drag windows/levels, right-drag zooms, middle-drag pans, the wheel scrolls the
|
|
86
|
+
stack.
|
|
87
|
+
|
|
88
|
+
## `<CornerstoneViewport>`
|
|
89
|
+
|
|
90
|
+
A stack viewport. Registered as a client-only component, so you never need `<ClientOnly>` around it.
|
|
91
|
+
|
|
92
|
+
| Prop | Type | Default | |
|
|
93
|
+
| --- | --- | --- | --- |
|
|
94
|
+
| `imageIds` | `string[]` | required | `wadouri:` / `wadors:` / `dicomfile:` ids, in display order |
|
|
95
|
+
| `imageIndex` | `number` | `0` | index into `imageIds` |
|
|
96
|
+
| `viewportId` | `string` | generated | |
|
|
97
|
+
| `renderingEngineId` | `string` | module option | viewports sharing an id share one engine |
|
|
98
|
+
| `toolGroupId` | `string` | module option | |
|
|
99
|
+
| `background` | `[number, number, number]` | `[0, 0, 0]` | canvas background, RGB in 0..1 |
|
|
100
|
+
| `defaultTool` | `string \| false` | `'WindowLevelTool'` | bound to left-drag on mount |
|
|
101
|
+
|
|
102
|
+
Events: `ready(viewport)`, `imageRendered`, `imageIndexChange(index)`, `error(error)`.
|
|
103
|
+
|
|
104
|
+
Exposed: `viewport`, `viewportId`, `renderingEngineId`, `status`, `error`, `setImageIndex(index)`,
|
|
105
|
+
`resetCamera()`, `getRenderingEngine()`.
|
|
106
|
+
|
|
107
|
+
The default slot receives `{ status, error, viewport }` for overlays.
|
|
108
|
+
|
|
109
|
+
`imageIndex` changes collapse rather than queue. `setImageIdIndex` resolves only once the slice has
|
|
110
|
+
been loaded and drawn, which can take longer than the gap between requests — cine playback asks for
|
|
111
|
+
a frame every 30 ms or so, and scrubbing fires as fast as the pointer moves. Only one change is
|
|
112
|
+
ever in flight; whatever arrives while it runs replaces the previous waiting one, so the viewport
|
|
113
|
+
follows the newest index asked for rather than the last load to finish.
|
|
114
|
+
|
|
115
|
+
The element must have a size — give it a height. It waits for a non-zero box before enabling the
|
|
116
|
+
viewport, because Cornerstone sizes its canvas from the element and a zero-sized element produces a
|
|
117
|
+
camera that never recovers. A viewport that starts inside a collapsed panel therefore comes up when
|
|
118
|
+
the panel opens, not before. Resizes are followed with a `ResizeObserver`, keeping the user's
|
|
119
|
+
pan/zoom.
|
|
120
|
+
|
|
121
|
+
## Composables
|
|
122
|
+
|
|
123
|
+
All are auto-imported.
|
|
124
|
+
|
|
125
|
+
**`useCornerstone()`** → `{ libs, ready, error, pending, ensure, options }`. Calling it starts
|
|
126
|
+
initialisation; `ensure()` resolves with `{ core, tools, dicomImageLoader }`. Initialisation is
|
|
127
|
+
shared, so calling this from ten components still initialises once.
|
|
128
|
+
|
|
129
|
+
**`useDicomFiles()`** → `{ addFiles, addZip, toImageId, indexUrls, purge, imageIdForSopInstanceUid }`.
|
|
130
|
+
`addFiles(files)` registers local `File`s and returns `dicomfile:` imageIds sorted by
|
|
131
|
+
**InstanceNumber** — read from tag (0020,0013) with `dicom-parser`, stopping at that tag, and
|
|
132
|
+
falling back to a numeric-aware filename sort for files that do not carry it. Pass
|
|
133
|
+
`{ sort: 'name' }` or `{ sort: false }` to change that. `toImageId(url)` builds a `wadouri:` id for
|
|
134
|
+
a Part 10 file served over HTTP. `addZip(file)` unpacks a ZIP archive — see below.
|
|
135
|
+
`indexUrls(urls)` reads the headers of files served that way so imported annotations can find them,
|
|
136
|
+
which `toImageId()` alone does not do. `imageIdForSopInstanceUid(uid)` answers where a slice ended
|
|
137
|
+
up, which is how annotations from elsewhere find their image — see
|
|
138
|
+
[Imported annotations](#imported-annotations).
|
|
139
|
+
|
|
140
|
+
**`useCornerstoneI18n()`** → `{ locale, setLocale, availableLocales, dir, isRtl, t, n, formatBytes,
|
|
141
|
+
addMessages, setTranslator }`. Locale and translation for the strings this module produces — see
|
|
142
|
+
[Internationalisation](#internationalisation).
|
|
143
|
+
|
|
144
|
+
### ZIP archives
|
|
145
|
+
|
|
146
|
+
`addZip()` takes a `File`, `Blob`, `ArrayBuffer` or `Uint8Array` and returns the images inside it,
|
|
147
|
+
**split into series**:
|
|
148
|
+
|
|
149
|
+
```vue
|
|
150
|
+
<script setup lang="ts">
|
|
151
|
+
import type { DicomSeries } from 'nuxt-cornerstone'
|
|
152
|
+
|
|
153
|
+
const { addZip } = useDicomFiles()
|
|
154
|
+
const series = shallowRef<DicomSeries[]>([])
|
|
155
|
+
const imageIds = ref<string[]>([])
|
|
156
|
+
|
|
157
|
+
async function open(file: File) {
|
|
158
|
+
const result = await addZip(file, {
|
|
159
|
+
onProgress: ({ phase, done, total }) => console.log(phase, done, '/', total),
|
|
160
|
+
})
|
|
161
|
+
series.value = result.series
|
|
162
|
+
imageIds.value = result.series[0]?.imageIds ?? []
|
|
163
|
+
}
|
|
164
|
+
</script>
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Each `DicomSeries` carries `seriesInstanceUid`, `seriesNumber`, `description`, `modality`, a
|
|
168
|
+
ready-made `label` and its own sorted `imageIds`. Series come back ordered by SeriesNumber. The
|
|
169
|
+
result also has a flat `imageIds` across every series, and `skipped`, listing the archive members
|
|
170
|
+
that were not loaded and why.
|
|
171
|
+
|
|
172
|
+
Splitting by SeriesInstanceUID (0020,000E) is the default because a study ZIP normally holds
|
|
173
|
+
several series, and stacking a sagittal T1 on top of an axial T2 is not a stack. Files whose header
|
|
174
|
+
does not give a SeriesInstanceUID fall back to grouping by their folder inside the archive, which is
|
|
175
|
+
how burned CDs lay series out anyway. Pass `{ groupBy: false }` for a single group holding
|
|
176
|
+
everything.
|
|
177
|
+
|
|
178
|
+
Archive members are filtered twice. Before anything is inflated, entries are dropped by name and
|
|
179
|
+
declared size — directories, `__MACOSX/`, dotfiles, `DICOMDIR`, `Thumbs.db`, and extensions that are
|
|
180
|
+
never DICOM (`.pdf`, `.jpg`, `.txt` and friends). There is no allowlist in the other direction,
|
|
181
|
+
because plenty of DICOM files are named `IM000001` or `I10`.
|
|
182
|
+
|
|
183
|
+
What survives is then judged on its bytes, and has to prove itself. A Part 10 file says so with the
|
|
184
|
+
`DICM` magic at offset 128. A dataset stored without a preamble has nothing to declare, so its
|
|
185
|
+
structure is read instead: the first element must open a group a dataset may legitimately open with
|
|
186
|
+
(file meta, or the identifying module), and the elements after it must parse and ascend. Anything
|
|
187
|
+
that can show neither is skipped as `not-dicom`.
|
|
188
|
+
|
|
189
|
+
A name is not evidence in either direction — a PNG renamed to `.dcm` is still a PNG, and it is
|
|
190
|
+
rejected here on its first tag, whose group reads as `0x5089`.
|
|
191
|
+
|
|
192
|
+
| Option | Default | |
|
|
193
|
+
| --- | --- | --- |
|
|
194
|
+
| `groupBy` | `'series'` | `false` returns one group |
|
|
195
|
+
| `sort` | `'instanceNumber'` | as `addFiles`; applies within each series |
|
|
196
|
+
| `maxBytes` | `2 GiB` | ceiling on total *uncompressed* bytes |
|
|
197
|
+
| `onProgress` | — | `{ phase, done, total }` |
|
|
198
|
+
|
|
199
|
+
`maxBytes` is checked against the sizes the archive's central directory declares, so a 100 KB file
|
|
200
|
+
claiming to expand to 40 GB is rejected without allocating for it.
|
|
201
|
+
|
|
202
|
+
`onProgress` reports three phases. `'reading'` and `'extracting'` have nothing to count — fflate
|
|
203
|
+
reports nothing until inflation finishes — so show an indeterminate bar for those; `'indexing'`
|
|
204
|
+
counts DICOM headers and fills in `done`/`total`. Indexing yields to the event loop every 32 files,
|
|
205
|
+
so a large study does not freeze the page while it is read.
|
|
206
|
+
|
|
207
|
+
Decompression uses [`fflate`](https://github.com/101arrowz/fflate), imported dynamically: an app
|
|
208
|
+
that never opens an archive never loads it.
|
|
209
|
+
|
|
210
|
+
**`useCornerstoneTools(toolGroupId?)`** → `{ ensureGroup, addViewport, removeViewport, setActive,
|
|
211
|
+
setPassive, setEnabled, setDisabled, getActiveTool, destroy }`. Takes either a class name
|
|
212
|
+
(`'LengthTool'`) or a tool name (`'Length'`). `setActive` sets the tool that held the left-drag
|
|
213
|
+
binding to *passive* rather than disabled, so annotations it drew stay visible and selectable.
|
|
214
|
+
|
|
215
|
+
**`useRenderingEngine()`** → `{ acquire, release, get }`. Engines are shared per id and reference
|
|
216
|
+
counted; the last viewport to leave destroys the engine. `core.init()` allocates a pool of WebGL
|
|
217
|
+
contexts (7 by default) and each engine takes one, so four viewports should share one engine rather
|
|
218
|
+
than create four.
|
|
219
|
+
|
|
220
|
+
**`useImagePrefetch()`** → `{ prepare, cancel, reset, isPrepared, loaded, failed, total, pending,
|
|
221
|
+
progress, complete, cacheFull }`. Decodes a stack into Cornerstone's image cache ahead of time.
|
|
222
|
+
|
|
223
|
+
**`useCinePlayer(index, frameCount, options?)`** → `{ playing, frameRate, loop, bounce, direction,
|
|
224
|
+
canPlay, play, pause, toggle, setFrameRate, setDirection }`. Plays a stack as a film by advancing
|
|
225
|
+
the index ref you give it. Both are covered under [Cine playback](#cine-playback).
|
|
226
|
+
|
|
227
|
+
**`useDicomAnnotations(viewport)`** → `{ addBoxes, addJson, clear, setVisible, drawn, pending,
|
|
228
|
+
visible }`. Draws boxes that were produced somewhere else — see below.
|
|
229
|
+
|
|
230
|
+
### Cine playback
|
|
231
|
+
|
|
232
|
+
A stack viewport loads each slice at the moment it is shown. That is right for scrolling and wrong
|
|
233
|
+
for anything that moves on its own: at 15 frames a second the decoder never keeps up, and playback
|
|
234
|
+
stutters through whatever happens to be cached. So the two halves go together — prepare the stack,
|
|
235
|
+
then play it.
|
|
236
|
+
|
|
237
|
+
```vue
|
|
238
|
+
<script setup lang="ts">
|
|
239
|
+
const imageIds = ref<string[]>([])
|
|
240
|
+
const imageIndex = ref(0)
|
|
241
|
+
|
|
242
|
+
const prefetch = useImagePrefetch()
|
|
243
|
+
const cine = useCinePlayer(imageIndex, () => imageIds.value.length, { frameRate: 15 })
|
|
244
|
+
|
|
245
|
+
// Prepare as soon as a stack has loaded, rather than making the user ask:
|
|
246
|
+
// by the time they reach for play, the film is already in the cache. A new
|
|
247
|
+
// stack cancels the run the old one started.
|
|
248
|
+
watch(imageIds, (ids) => {
|
|
249
|
+
cine.pause()
|
|
250
|
+
prefetch.reset()
|
|
251
|
+
if (ids.length > 1) prefetch.prepare(ids, { order: 'forward' })
|
|
252
|
+
})
|
|
253
|
+
</script>
|
|
254
|
+
|
|
255
|
+
<template>
|
|
256
|
+
<CornerstoneViewport
|
|
257
|
+
:image-ids="imageIds"
|
|
258
|
+
:image-index="imageIndex"
|
|
259
|
+
@image-index-change="imageIndex = $event"
|
|
260
|
+
/>
|
|
261
|
+
<button @click="cine.toggle()">{{ cine.playing.value ? 'Pause' : 'Play' }}</button>
|
|
262
|
+
<progress :value="prefetch.progress.value" />
|
|
263
|
+
</template>
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
`prepare(imageIds, options)` resolves once every image has been through the loader, and reports
|
|
267
|
+
`{ loaded, failed, total, cancelled, cacheFull }`. Follow it live on the refs instead — `progress`
|
|
268
|
+
is 0..1, and `pending` is true while it runs. Images already in the cache are counted and skipped,
|
|
269
|
+
so preparing again after adding slices only fetches the new ones.
|
|
270
|
+
|
|
271
|
+
| Option | Default | |
|
|
272
|
+
| --- | --- | --- |
|
|
273
|
+
| `from` | `0` | index to start from — pass the slice on screen |
|
|
274
|
+
| `order` | `'outward'` | `'outward'` fans out either side of `from`; `'forward'` runs to the end and wraps |
|
|
275
|
+
| `concurrency` | `4` | images decoded at once |
|
|
276
|
+
| `priority` | `0` | passed to Cornerstone's loader; lower runs sooner |
|
|
277
|
+
| `requestType` | `'prefetch'` | the request class the pool serves after anything the user waits on |
|
|
278
|
+
| `onProgress` | — | `{ loaded, failed, total }` after each image settles |
|
|
279
|
+
|
|
280
|
+
One instance runs one prepare at a time: a second call cancels the first, which is what you want
|
|
281
|
+
when the user switches series mid-load. `cancel()` also abandons the requests in flight, and the
|
|
282
|
+
composable calls it for you when its scope is disposed. Counters keep their values so a
|
|
283
|
+
half-prepared stack can still say so; `reset()` clears them.
|
|
284
|
+
|
|
285
|
+
A slice that will not decode is counted in `failed` and the rest continue. If the image cache fills
|
|
286
|
+
up, the run stops rather than thrashing — each new slice would only evict one just decoded — and
|
|
287
|
+
`cacheFull` says so. Raise the ceiling with `cache.setMaxCacheSize()` if a whole study has to fit.
|
|
288
|
+
|
|
289
|
+
`useCinePlayer()` owns no images and no viewport. It advances the index ref, and whatever is bound
|
|
290
|
+
to that index follows, so a scrubber, the keyboard and the player all move the same value. Frames
|
|
291
|
+
are paced with `requestAnimationFrame` measured against the clock, so the rate does not drift and a
|
|
292
|
+
backgrounded tab stops advancing instead of spending the battery. A frame that arrives late is
|
|
293
|
+
shown late and the ones behind it are dropped, rather than the stack sprinting to catch up.
|
|
294
|
+
|
|
295
|
+
| Option | Default | |
|
|
296
|
+
| --- | --- | --- |
|
|
297
|
+
| `frameRate` | `15` | clamped to 1..60; `frameRate` is writable, so a control can `v-model` it |
|
|
298
|
+
| `loop` | `true` | `false` stops at the end |
|
|
299
|
+
| `bounce` | `false` | reverse at each end instead of jumping — what a cardiac cine wants |
|
|
300
|
+
| `direction` | `1` | `-1` plays towards the first image |
|
|
301
|
+
|
|
302
|
+
`canPlay` is false for a stack of one, because a single image is a picture rather than a film. Draw
|
|
303
|
+
the transport from it rather than only disabling by it — a play button on a series of one has
|
|
304
|
+
nothing to do in any state, so leaving it out says so more clearly than greying it out. The player
|
|
305
|
+
pauses itself when the stack is replaced or emptied.
|
|
306
|
+
|
|
307
|
+
### Imported annotations
|
|
308
|
+
|
|
309
|
+
Boxes from a reporting service or a detection model are drawn onto the stack with
|
|
310
|
+
`useDicomAnnotations()`. It takes the viewport — a ref, a getter, or the object itself — and boxes
|
|
311
|
+
in **image pixel coordinates**, each naming the slice it belongs to by **SOPInstanceUID**:
|
|
312
|
+
|
|
313
|
+
```vue
|
|
314
|
+
<script setup lang="ts">
|
|
315
|
+
import type { Types as CoreTypes } from '@cornerstonejs/core'
|
|
316
|
+
|
|
317
|
+
const viewport = shallowRef<CoreTypes.IStackViewport | null>(null)
|
|
318
|
+
const { addZip } = useDicomFiles()
|
|
319
|
+
const { addBoxes, setVisible, visible } = useDicomAnnotations(viewport)
|
|
320
|
+
|
|
321
|
+
async function open(archive: Blob, report: Finding[]) {
|
|
322
|
+
await addZip(archive)
|
|
323
|
+
|
|
324
|
+
const { drawn, deferred, unresolved } = await addBoxes(
|
|
325
|
+
report.map(finding => ({
|
|
326
|
+
sopInstanceUid: finding.sopInstanceUid,
|
|
327
|
+
box: { x1: finding.left, y1: finding.top, x2: finding.right, y2: finding.bottom },
|
|
328
|
+
label: `${finding.name} ${Math.round(finding.confidence * 100)}%`,
|
|
329
|
+
uid: finding.id,
|
|
330
|
+
})),
|
|
331
|
+
{ color: 'rgb(251, 191, 36)' },
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if (unresolved.length) console.warn(`${unresolved.length} boxes name slices that are not loaded`)
|
|
335
|
+
}
|
|
336
|
+
</script>
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
**SOPInstanceUID (0008,0018) is how a box finds its slice**, because it is the only identifier that
|
|
340
|
+
survives leaving the viewer: `dicomfile:` imageIds are handed out by the loader as files are
|
|
341
|
+
registered, so the same study opened twice has a different set of them. `addFiles()` and `addZip()`
|
|
342
|
+
record the UID of every file whose header they read, which is every file on the default settings —
|
|
343
|
+
`sort: false` and `sort: 'name'` skip header reading, and a file that was not indexed cannot be
|
|
344
|
+
found by UID. Boxes naming a slice that is not loaded come back in `unresolved` instead of throwing,
|
|
345
|
+
because a study and a report disagreeing about which slices exist is a normal thing to show the
|
|
346
|
+
user rather than an error.
|
|
347
|
+
|
|
348
|
+
**Boxes are placed lazily.** Pixel coordinates become world coordinates through the slice's own
|
|
349
|
+
image plane, and the metadata provider only holds that once the slice has been loaded. A box whose
|
|
350
|
+
slice is already loaded is drawn immediately and counted in `drawn`; the rest are held and counted
|
|
351
|
+
in `deferred`, then drawn the first time their slice is shown. Loading 500 slices up front to place
|
|
352
|
+
boxes the user may never scroll to would cost more than it saves.
|
|
353
|
+
|
|
354
|
+
**They are read-only.** Boxes are locked, so they cannot be dragged, resized or deleted, and they
|
|
355
|
+
are drawn with a RectangleROI instance of their own — registered as `DicomBoxOverlay` — rather than
|
|
356
|
+
with RectangleROI itself. That keeps the user's own measurements separately styled and separately
|
|
357
|
+
selectable, and lets an imported box show the label it arrived with instead of the area and mean a
|
|
358
|
+
measurement shows. Pass `{ locked: false }` if they should be editable.
|
|
359
|
+
|
|
360
|
+
`setVisible(false)` hides the boxes without discarding them; `clear()` removes every box this
|
|
361
|
+
composable drew and leaves the user's own measurements alone. Boxes are keyed to the imageIds that
|
|
362
|
+
were loaded when they were added, so clear them when the stack changes.
|
|
363
|
+
|
|
364
|
+
| Option | Default | |
|
|
365
|
+
| --- | --- | --- |
|
|
366
|
+
| `color` | Cornerstone's locked colour | any CSS colour; applies to imported boxes only |
|
|
367
|
+
| `locked` | `true` | `false` makes them editable |
|
|
368
|
+
|
|
369
|
+
### Annotation files
|
|
370
|
+
|
|
371
|
+
`addJson(file)` is the same thing starting from a JSON file rather than from objects you have
|
|
372
|
+
already built. Open the DICOM images first — the boxes are matched to the slices that are loaded —
|
|
373
|
+
then hand it whatever the user picked, dropped or you fetched: a `File`, a `Blob`, the JSON text, or
|
|
374
|
+
an object that is already parsed.
|
|
375
|
+
|
|
376
|
+
```vue
|
|
377
|
+
<script setup lang="ts">
|
|
378
|
+
const viewport = shallowRef<CoreTypes.IStackViewport | null>(null)
|
|
379
|
+
const { addJson } = useDicomAnnotations(viewport)
|
|
380
|
+
|
|
381
|
+
async function openReport(file: File) {
|
|
382
|
+
const { drawn, deferred, report } = await addJson(file, { color: 'rgb(251, 191, 36)' })
|
|
383
|
+
|
|
384
|
+
if (drawn + deferred === 0 && report.boxes.length) {
|
|
385
|
+
// Parsed fine, matched nothing: normally a report for a different series.
|
|
386
|
+
console.warn(`report belongs to series ${report.seriesInstanceUid}`)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
</script>
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Two layouts are read. The first is this module's own — a list of boxes, either as the whole document
|
|
393
|
+
or under `boxes` / `annotations`:
|
|
394
|
+
|
|
395
|
+
```json
|
|
396
|
+
[{ "sopInstanceUid": "1.2.3", "box": { "x1": 10, "y1": 20, "x2": 80, "y2": 90 }, "label": "Nodule 1" }]
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The second is a detector's output: findings, each holding the slices it was seen on, optionally
|
|
400
|
+
wrapped in `predictions`.
|
|
401
|
+
|
|
402
|
+
```json
|
|
403
|
+
{ "predictions": { "findings": [
|
|
404
|
+
{ "confidence": 0.87, "label": 1, "slice_findings": [
|
|
405
|
+
{ "sop_instance_uid": "1.2.3",
|
|
406
|
+
"bounding_box": { "upper_left_x": 10, "upper_left_y": 20,
|
|
407
|
+
"lower_right_x": 80, "lower_right_y": 90 } }
|
|
408
|
+
] }
|
|
409
|
+
] } }
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
Field names are read in both `camelCase` and `snake_case`, and a box may be written as two opposite
|
|
413
|
+
corners, as `xMin`/`xMax`, as an origin with `width` and `height`, or as a bare `[x1, y1, x2, y2]`.
|
|
414
|
+
A finding's own `bounding_box` is deliberately ignored: it is in the volume the model ran on, not in
|
|
415
|
+
the pixel space of the images on screen, so only `slice_findings` is read. Anything else throws with
|
|
416
|
+
a message naming what was expected; individual rows that cannot be read are counted in
|
|
417
|
+
`report.malformed` rather than discarding the rest of the file.
|
|
418
|
+
|
|
419
|
+
Boxes read from the detector shape are captioned with the finding's name — or its class — and its
|
|
420
|
+
confidence, and are given a uid of `finding-<n>-<sopInstanceUid>`, so opening the same report twice
|
|
421
|
+
replaces its boxes instead of stacking a second set on the first. Pass `label` to write your own
|
|
422
|
+
caption, and `minConfidence` to drop findings below a threshold.
|
|
423
|
+
|
|
424
|
+
| Option | Default | |
|
|
425
|
+
| --- | --- | --- |
|
|
426
|
+
| `minConfidence` | `0` | 0..1; detector shape only |
|
|
427
|
+
| `label` | name + confidence | `(finding) => string` |
|
|
428
|
+
|
|
429
|
+
`addJson` also takes `color` and `locked`, which it passes to `addBoxes`. It returns what `addBoxes`
|
|
430
|
+
returns, plus `report`: `{ boxes, format, findings, filtered, malformed, seriesInstanceUid }`. For
|
|
431
|
+
files served over HTTP, call `indexUrls()` before drawing — `toImageId()` builds an imageId without
|
|
432
|
+
reading the file, so nothing would know its SOPInstanceUID.
|
|
433
|
+
|
|
434
|
+
## Building a viewer
|
|
435
|
+
|
|
436
|
+
The composables above are the primitives. Assembling them into something a
|
|
437
|
+
radiologist can use — open a study, switch series, scrub, play, report what
|
|
438
|
+
went wrong — is the same work in every application, so it is here too, in a
|
|
439
|
+
second tier of composables that produce **state and translated captions but no
|
|
440
|
+
markup**. You bring the buttons.
|
|
441
|
+
|
|
442
|
+
```vue
|
|
443
|
+
<script setup lang="ts">
|
|
444
|
+
import type { Types as CoreTypes } from '@cornerstonejs/core'
|
|
445
|
+
|
|
446
|
+
const study = useDicomStudy()
|
|
447
|
+
const viewport = shallowRef<CoreTypes.IStackViewport | null>(null)
|
|
448
|
+
const cine = useStackCine(study.imageIds, study.imageIndex)
|
|
449
|
+
const report = useAnnotationReport(viewport)
|
|
450
|
+
const tools = useCornerstoneTools()
|
|
451
|
+
|
|
452
|
+
useViewerShortcuts({
|
|
453
|
+
isEnabled: () => study.imageIds.value.length > 0,
|
|
454
|
+
step: study.step,
|
|
455
|
+
first: () => (study.imageIndex.value = 0),
|
|
456
|
+
last: () => (study.imageIndex.value = study.maxIndex.value),
|
|
457
|
+
stepSeries: study.stepSeries,
|
|
458
|
+
setTool: tools.setActive,
|
|
459
|
+
resetViewport: () => viewport.value?.resetCamera(),
|
|
460
|
+
togglePlay: cine.toggle,
|
|
461
|
+
toggleHelp: () => {},
|
|
462
|
+
})
|
|
463
|
+
</script>
|
|
464
|
+
|
|
465
|
+
<template>
|
|
466
|
+
<input type="file" multiple @change="study.openAny(Array.from($event.target.files ?? []))">
|
|
467
|
+
<p v-if="study.problemText.value">{{ study.problemText }}</p>
|
|
468
|
+
|
|
469
|
+
<div style="height: 600px">
|
|
470
|
+
<CornerstoneViewport
|
|
471
|
+
:image-ids="study.imageIds.value"
|
|
472
|
+
:image-index="study.imageIndex.value"
|
|
473
|
+
@ready="viewport = $event"
|
|
474
|
+
@image-index-change="study.imageIndex.value = $event"
|
|
475
|
+
/>
|
|
476
|
+
</div>
|
|
477
|
+
|
|
478
|
+
<button :disabled="cine.buffering.value" @click="cine.toggle">Play</button>
|
|
479
|
+
<small>{{ study.sourceLabel }} · {{ cine.statusText }}</small>
|
|
480
|
+
</template>
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
**`useDicomStudy()`** → `{ ready, imageIds, imageIndex, maxIndex, series, activeSeriesUid, source,
|
|
484
|
+
sourceLabel, busy, progress, progressLabel, progressValue, problemText, rejectedText, openAny,
|
|
485
|
+
openFiles, openZip, openUrls, selectSeries, step, stepSeries, clear, setProblem }`.
|
|
486
|
+
|
|
487
|
+
Everything about the stack on screen. `openAny(files)` is the one way in for a picked or dropped
|
|
488
|
+
selection: an archive is unpacked and anything else is vetted file by file, on content rather than
|
|
489
|
+
on the name, so a PNG renamed `.dcm` is turned away here with its name attached instead of failing
|
|
490
|
+
later at render with an error that points at the viewport. `openUrls(urls)` is the path for a study
|
|
491
|
+
served by your own backend; it reads each file's headers afterwards so an annotation report can find
|
|
492
|
+
the slices. The `*Label` and `*Text` members are computed, already translated, and re-render on a
|
|
493
|
+
locale switch — they hold data rather than a finished string for exactly that reason.
|
|
494
|
+
|
|
495
|
+
**`useStackCine(imageIds, imageIndex)`** → `{ playing, canPlay, frameRate, loop, preparing,
|
|
496
|
+
prepared, buffering, percent, statusText, play, pause, toggle, prepare }`.
|
|
497
|
+
|
|
498
|
+
[`useCinePlayer()`](#cine-playback) and `useImagePrefetch()` wired together, which is what a
|
|
499
|
+
transport actually needs: a stack viewport loads each slice as it shows it, so playing a stack
|
|
500
|
+
nobody has prepared runs at the speed of the decoder rather than at the frame rate that was asked
|
|
501
|
+
for. A stack prepares itself as soon as it loads, and `buffering` holds play until
|
|
502
|
+
`PLAY_READY_RATIO` — half — of it is decoded. Half is a comfortable head start, because the film and
|
|
503
|
+
the prefetch both run from the first image.
|
|
504
|
+
|
|
505
|
+
**`useAnnotationReport(viewport)`** → `{ open, toggle, reset, busy, loaded, visible, summaryText,
|
|
506
|
+
failure, drawn, pending }`.
|
|
507
|
+
|
|
508
|
+
[`useDicomAnnotations()`](#imported-annotations) as a user action: which file is open, whether its
|
|
509
|
+
boxes are showing, and what to say afterwards. The case worth having a sentence for is a file that
|
|
510
|
+
parsed perfectly and placed nothing, which almost always means the report belongs to another series.
|
|
511
|
+
Call `reset()` when the stack changes — boxes are keyed to the imageIds that were loaded when they
|
|
512
|
+
were drawn.
|
|
513
|
+
|
|
514
|
+
**`useMeasurements(viewport)`** → `{ list, refresh, remove, deleteSelected, deleteOnSlice,
|
|
515
|
+
deleteAll, total, onSlice, selected }`.
|
|
516
|
+
|
|
517
|
+
Deleting the measurements the reader drew. Cornerstone gives every annotation tool a way to draw and
|
|
518
|
+
no way to un-draw, so a stray Length or a mis-clicked Probe stays on the slice for as long as the
|
|
519
|
+
study is open. This is the other half: the counts a toolbar needs to decide what to offer, and the
|
|
520
|
+
three removals worth offering.
|
|
521
|
+
|
|
522
|
+
```vue
|
|
523
|
+
<script setup lang="ts">
|
|
524
|
+
const viewport = shallowRef<CoreTypes.IStackViewport | null>(null)
|
|
525
|
+
const measurements = useMeasurements(viewport)
|
|
526
|
+
</script>
|
|
527
|
+
|
|
528
|
+
<template>
|
|
529
|
+
<!-- Nothing drawn yet, nothing to offer. -->
|
|
530
|
+
<template v-if="measurements.total.value">
|
|
531
|
+
<button @click="measurements.deleteOnSlice()">
|
|
532
|
+
Clear this image ({{ measurements.onSlice.value }})
|
|
533
|
+
</button>
|
|
534
|
+
<button @click="measurements.deleteAll()">
|
|
535
|
+
Clear all ({{ measurements.total.value }})
|
|
536
|
+
</button>
|
|
537
|
+
</template>
|
|
538
|
+
</template>
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Only the reader's own work is in scope. Boxes drawn by [`useDicomAnnotations()`](#imported-annotations)
|
|
542
|
+
are excluded by tool name and anything locked is excluded outright, so a delete button cannot erase
|
|
543
|
+
an imported report — `useAnnotationReport().reset()` is what takes those away. Pass `{ keep: [...] }`
|
|
544
|
+
to exclude tools of your own as well.
|
|
545
|
+
|
|
546
|
+
Scope is the stack on screen, not the annotation state manager, which outlives a study: a
|
|
547
|
+
measurement drawn on a series that has since been closed is neither counted nor deleted. The counts
|
|
548
|
+
follow the annotation state — they move when the user draws, deletes, selects or scrolls — so a
|
|
549
|
+
toolbar can read them directly rather than recomputing them.
|
|
550
|
+
|
|
551
|
+
**`useViewerShortcuts(handlers, { tools })`** and **`releaseFocus(event)`**.
|
|
552
|
+
|
|
553
|
+
The [OHIF](https://ohif.org) keymap: arrows and Home/End through the stack, PageUp/PageDown through
|
|
554
|
+
the series, `W`/`P`/`Z` and friends for tools, `R` to reset, Space or `C` for cine, `?` for help.
|
|
555
|
+
Delete and Backspace remove the selected measurements when you supply `deleteMeasurement` — both
|
|
556
|
+
keys, because the one a reader reaches for is whichever their keyboard has; leave the handler out
|
|
557
|
+
and the keys stay with the browser.
|
|
558
|
+
Bindings match `event.code`, the physical key, not `event.key`: on a Persian layout the W key
|
|
559
|
+
reports `'ش'` and on a Russian one `'ц'`, so a `key`-based binding silently stops working for
|
|
560
|
+
anyone not on QWERTY. Pass `tools` so your toolbar and the keymap read from one table and the key a
|
|
561
|
+
button advertises is the key that works; the default is `DEFAULT_TOOL_SHORTCUTS`. Widgets that read
|
|
562
|
+
the keyboard themselves — anything with a text, combobox, listbox or menu role, and anything inside
|
|
563
|
+
an open dialog — are left alone. `releaseFocus` goes on a toolbar's root: a button clicked with the
|
|
564
|
+
mouse keeps focus and then swallows the spacebar, so picking a tool and pressing play appears to do
|
|
565
|
+
nothing.
|
|
566
|
+
|
|
567
|
+
**`guardDicomFiles(files)`** → `{ accepted, rejected }`. The content check `openFiles()` runs, on
|
|
568
|
+
its own, for an application that sorts its own selection.
|
|
569
|
+
|
|
570
|
+
## Options
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
export default defineNuxtConfig({
|
|
574
|
+
modules: ['nuxt-cornerstone'],
|
|
575
|
+
cornerstone: {
|
|
576
|
+
autoInit: true,
|
|
577
|
+
core: {}, // -> coreInit(config)
|
|
578
|
+
dicomImageLoader: {}, // -> dicomImageLoaderInit(options)
|
|
579
|
+
tools: { enabled: true, register: [/* class names */] },
|
|
580
|
+
i18n: { // see Internationalisation
|
|
581
|
+
locale: 'en',
|
|
582
|
+
fallbackLocale: 'en',
|
|
583
|
+
messages: {},
|
|
584
|
+
numberingSystem: 'auto',
|
|
585
|
+
detect: false, // or true to follow the host app's locale
|
|
586
|
+
},
|
|
587
|
+
viteCommonjs: true,
|
|
588
|
+
prefix: 'Cornerstone',
|
|
589
|
+
renderingEngineId: 'nuxt-cornerstone',
|
|
590
|
+
toolGroupId: 'nuxt-cornerstone-tools',
|
|
591
|
+
},
|
|
592
|
+
})
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
Registered tools by default: `WindowLevelTool`, `PanTool`, `ZoomTool`, `StackScrollTool`,
|
|
596
|
+
`LengthTool`, `RectangleROITool`, `EllipticalROITool`, `ProbeTool`. Add any other export of
|
|
597
|
+
`@cornerstonejs/tools` to `tools.register`.
|
|
598
|
+
|
|
599
|
+
### Callbacks
|
|
600
|
+
|
|
601
|
+
Options reach the browser through `runtimeConfig`, which is serialized into the page payload, so
|
|
602
|
+
functions cannot travel that way — `dicomImageLoader.beforeSend`, `core.peerImport` and friends. The
|
|
603
|
+
module warns at build time and drops them. Pass them at runtime instead:
|
|
604
|
+
|
|
605
|
+
```ts
|
|
606
|
+
// nuxt.config.ts → cornerstone: { autoInit: false }
|
|
607
|
+
|
|
608
|
+
// app/plugins/cornerstone.client.ts
|
|
609
|
+
export default defineNuxtPlugin(() => {
|
|
610
|
+
ensureCornerstone({
|
|
611
|
+
dicomImageLoader: {
|
|
612
|
+
beforeSend: xhr => ({ Authorization: `Bearer ${useAuth().token}` }),
|
|
613
|
+
},
|
|
614
|
+
})
|
|
615
|
+
})
|
|
616
|
+
```
|
|
617
|
+
|
|
618
|
+
## Internationalisation
|
|
619
|
+
|
|
620
|
+
The module ships `en` and `fa` (Farsi) and depends on no i18n library, because a viewer component
|
|
621
|
+
should not force one on the app that installs it. Both catalogues are bundled — they are a few
|
|
622
|
+
hundred bytes each, and the strings are needed synchronously, on paths like `asZipError()` that have
|
|
623
|
+
to return an `Error` rather than a promise of one.
|
|
624
|
+
|
|
625
|
+
```ts
|
|
626
|
+
// nuxt.config.ts
|
|
627
|
+
export default defineNuxtConfig({
|
|
628
|
+
cornerstone: {
|
|
629
|
+
i18n: {
|
|
630
|
+
locale: 'fa',
|
|
631
|
+
fallbackLocale: 'en',
|
|
632
|
+
// Merged over the built-ins. Override a string, add a locale, or
|
|
633
|
+
// register keys of your own — `t()` resolves anything in here.
|
|
634
|
+
messages: {
|
|
635
|
+
fa: { 'series.unnamed': 'سری ناشناس' },
|
|
636
|
+
},
|
|
637
|
+
// 'auto' (default) gives Farsi its Persian-Indic digits: ۱۲ تصویر.
|
|
638
|
+
// 'latn' pins every locale to 0-9, for users who cross-reference slice
|
|
639
|
+
// numbers against another system.
|
|
640
|
+
numberingSystem: 'auto',
|
|
641
|
+
},
|
|
642
|
+
},
|
|
643
|
+
})
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
```vue
|
|
647
|
+
<script setup lang="ts">
|
|
648
|
+
const { t, n, locale, dir, isRtl, availableLocales } = useCornerstoneI18n()
|
|
649
|
+
</script>
|
|
650
|
+
|
|
651
|
+
<template>
|
|
652
|
+
<!-- `locale` is writable, and every viewer in the app follows it. -->
|
|
653
|
+
<select v-model="locale">
|
|
654
|
+
<option v-for="code in availableLocales" :key="code" :value="code">{{ code }}</option>
|
|
655
|
+
</select>
|
|
656
|
+
<p>{{ t('series.unnamed') }} — {{ n(1234) }}</p>
|
|
657
|
+
</template>
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
Set `<html lang dir>` from the same source, so the chrome flips with the locale:
|
|
661
|
+
|
|
662
|
+
```ts
|
|
663
|
+
// app.vue
|
|
664
|
+
const { locale, dir } = useCornerstoneI18n()
|
|
665
|
+
useHead({ htmlAttrs: { lang: locale, dir } }) // pass the refs, not `.value`
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
### Following the host application's locale
|
|
669
|
+
|
|
670
|
+
By default the module uses the locale you configure and nothing else. Set `detect` and it reads the
|
|
671
|
+
locale from the app that installed it instead:
|
|
672
|
+
|
|
673
|
+
```ts
|
|
674
|
+
// nuxt.config.ts
|
|
675
|
+
cornerstone: {
|
|
676
|
+
i18n: { detect: true }, // or pick the sources: ['i18n', 'html']
|
|
677
|
+
}
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
`true` tries each source in order and stops at the first that answers:
|
|
681
|
+
|
|
682
|
+
| Source | Reads | Follows changes |
|
|
683
|
+
| --- | --- | --- |
|
|
684
|
+
| `'i18n'` | `nuxtApp.$i18n.locale` — `@nuxtjs/i18n` and vue-i18n, in either Composition or legacy mode | yes, reactively |
|
|
685
|
+
| `'html'` | the `lang` attribute on `<html>`, which nearly every i18n library sets | yes, via `MutationObserver` |
|
|
686
|
+
| `'navigator'` | the browser's preferred language | no, read once at startup |
|
|
687
|
+
|
|
688
|
+
Nothing is imported to do this — `$i18n` is duck-typed, so the module gains no dependency and simply
|
|
689
|
+
declines when no i18n library is installed.
|
|
690
|
+
|
|
691
|
+
**Only a locale the module has a catalogue for is adopted.** An app running in a language nobody has
|
|
692
|
+
translated keeps the configured locale, rather than flipping text direction under the viewer to go
|
|
693
|
+
on showing English anyway. A host that *starts* in such a language is never hooked up at all, so it
|
|
694
|
+
cannot take the module over later either.
|
|
695
|
+
|
|
696
|
+
Two things worth knowing:
|
|
697
|
+
|
|
698
|
+
- **Detection is client-side.** The locale is module-scoped state, which on the server is shared by
|
|
699
|
+
every in-flight request — following a per-request locale there would let one request's language
|
|
700
|
+
leak into another's. During SSR the configured locale is used, so set `cornerstone.i18n.locale` to
|
|
701
|
+
whatever your app's default is and detection takes over after hydration. If you need a genuinely
|
|
702
|
+
per-request locale, use `i18n: false` and the translator below: your own i18n has request scope
|
|
703
|
+
and this does not.
|
|
704
|
+
- **Do not use the `'html'` source if you set `<html lang>` *from* this module's locale**, as the
|
|
705
|
+
playground does. It is a cycle. It settles, because adopting the locale that is already active
|
|
706
|
+
changes nothing, but you then have no source of truth.
|
|
707
|
+
|
|
708
|
+
### What gets translated
|
|
709
|
+
|
|
710
|
+
Everything the module produces itself: the errors it throws, the series labels
|
|
711
|
+
`useDicomFiles()` builds — `Series 3 (CT, 12 images)` becomes `سری ۳ (CT، ۱۲ تصویر)` — and every
|
|
712
|
+
caption the [viewer composables](#building-a-viewer) return, under the `study.*`, `cine.*` and
|
|
713
|
+
`report.*` keys. Identifiers stay in English inside every translation, because
|
|
714
|
+
`cornerstone.autoInit` and `ensureCornerstone()` are things you have to type or search for.
|
|
715
|
+
|
|
716
|
+
Those captions are computed from state rather than stored once, so they are rewritten on a locale
|
|
717
|
+
switch. That is the one place the rule below does not apply.
|
|
718
|
+
|
|
719
|
+
An error is translated when it is **thrown**, not when it is displayed. Switching locale does not
|
|
720
|
+
rewrite an error already sitting in a `ref`.
|
|
721
|
+
|
|
722
|
+
### RTL and the viewport
|
|
723
|
+
|
|
724
|
+
`<CornerstoneViewport>` sets `dir="ltr"` on itself and you should leave it there. In an RTL app the
|
|
725
|
+
surrounding chrome flips, but a DICOM image must not: left and right are facts about the patient,
|
|
726
|
+
and mirroring one turns a left-sided finding into a right-sided one. It is a default rather than a
|
|
727
|
+
hard-coded value, so an app with its own reason to change it still can by passing `dir`.
|
|
728
|
+
|
|
729
|
+
### Using your own i18n instead
|
|
730
|
+
|
|
731
|
+
If the app already runs vue-i18n, `@nuxtjs/i18n` or anything else, hand it these strings:
|
|
732
|
+
|
|
733
|
+
```ts
|
|
734
|
+
// nuxt.config.ts → cornerstone: { i18n: false }
|
|
735
|
+
|
|
736
|
+
// app/plugins/cornerstone-i18n.client.ts
|
|
737
|
+
export default defineNuxtPlugin(() => {
|
|
738
|
+
const { t } = useI18n()
|
|
739
|
+
const { setTranslator } = useCornerstoneI18n()
|
|
740
|
+
// Return `undefined` for a key you do not own and the built-in English
|
|
741
|
+
// answers instead, so you can take over three strings or all of them.
|
|
742
|
+
setTranslator((key, params) => (te(key) ? t(key, params) : undefined))
|
|
743
|
+
})
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
`i18n: false` keeps the module on English and makes `setLocale()` a no-op, so there is no second
|
|
747
|
+
locale quietly tracking alongside yours.
|
|
748
|
+
|
|
749
|
+
The locale is module-scoped state, shared by every request on the server. That is correct for a
|
|
750
|
+
locale fixed at build time and wrong for a per-request one, so `setLocale()` is a client-side call.
|
|
751
|
+
For per-request locales, keep `i18n: false` and let your own i18n — which has request scope — feed
|
|
752
|
+
the translator.
|
|
753
|
+
|
|
754
|
+
### Adding a locale
|
|
755
|
+
|
|
756
|
+
Nothing in the module is specific to `en` and `fa`. Plural forms come from `Intl.PluralRules`, so a
|
|
757
|
+
catalogue supplies whichever CLDR categories its language uses and missing ones fall back to
|
|
758
|
+
`other`; text direction is derived from the language subtag.
|
|
759
|
+
|
|
760
|
+
```ts
|
|
761
|
+
messages: {
|
|
762
|
+
ar: {
|
|
763
|
+
'series.unnamed': 'سلسلة بدون اسم',
|
|
764
|
+
'series.images': { zero: 'لا صور', one: 'صورة واحدة', two: 'صورتان', few: '{count} صور', other: '{count} صورة' },
|
|
765
|
+
},
|
|
766
|
+
}
|
|
767
|
+
```
|
|
768
|
+
|
|
769
|
+
## What the module does to Vite
|
|
770
|
+
|
|
771
|
+
On the client build only:
|
|
772
|
+
|
|
773
|
+
- **`optimizeDeps.exclude: ['@cornerstonejs/dicom-image-loader']`.** Each decoder locates its binary
|
|
774
|
+
with a bare `@cornerstonejs/codec-*` specifier inside `new URL(..., import.meta.url)`. Rollup
|
|
775
|
+
resolves that and emits the binary; esbuild does not, and dev prebundling is esbuild. Left in, the
|
|
776
|
+
request goes to a path that does not exist, the SPA fallback answers with `index.html`, and you
|
|
777
|
+
get `CompileError: WebAssembly.instantiate(): expected magic word 00 61 73 6d, found 3c 21 64 6f`
|
|
778
|
+
— `3c 21 64 6f` is `<!do`.
|
|
779
|
+
- **`optimizeDeps.include`** for `dicom-parser` (CommonJS), `fflate`, `@cornerstonejs/core`,
|
|
780
|
+
`@cornerstonejs/tools`, `@cornerstonejs/metadata` and `@cornerstonejs/utils`. Two reasons. Vite
|
|
781
|
+
would discover core and tools on the first dynamic import anyway, but discovering them *while the
|
|
782
|
+
browser is importing them* re-bundles, changes the dep hash and invalidates the in-flight URLs, so
|
|
783
|
+
the first load fails with "error loading dynamically imported module" and only recovers through a
|
|
784
|
+
full reload. And `metadata`/`utils` are singletons holding the metadata provider registry: reached
|
|
785
|
+
only transitively, the optimizer inlines one copy into core's chunk while the excluded image loader
|
|
786
|
+
resolves a second raw copy, the two stop sharing a registry, and every `wadouri` load fails with
|
|
787
|
+
`no pixel data in NATURALIZED`. Naming them makes them shared chunks. `fflate` is here for the
|
|
788
|
+
first reason too: it is imported dynamically the first time someone opens a ZIP, which is a click
|
|
789
|
+
rather than a page load, so discovering it only then would reload the page out from under the
|
|
790
|
+
archive the user just picked.
|
|
791
|
+
- **`worker.format: 'es'`.** The loader spawns
|
|
792
|
+
`new Worker(new URL('./decodeImageFrameWorker.js', import.meta.url), { type: 'module' })`.
|
|
793
|
+
- **`assetsInclude: ['**/*.wasm']`.**
|
|
794
|
+
- **`@originjs/vite-plugin-commonjs`** (`viteCommonjs: true`, on by default). The four Emscripten
|
|
795
|
+
codec bundles are UMD/CommonJS, and they are served raw because the loader that imports them is
|
|
796
|
+
excluded from prebundling. Without this, initialisation fails with
|
|
797
|
+
`doesn't provide an export named: 'default'`. Turn it off only if you supply your own interop.
|
|
798
|
+
|
|
799
|
+
It also adds the browser-only packages to `nitro.externals.external` so the server bundle cannot
|
|
800
|
+
inline them.
|
|
801
|
+
|
|
802
|
+
## Troubleshooting
|
|
803
|
+
|
|
804
|
+
**Deflated Explicit VR Little Endian (1.2.840.10008.1.2.1.99) fails to load** with `no pixel data in
|
|
805
|
+
NATURALIZED`. This is upstream: Cornerstone3D 5's default metadata path naturalises Part 10 with
|
|
806
|
+
dcmjs's `AsyncDicomReader`, which does not inflate the deflated dataset — it reads no elements past
|
|
807
|
+
the file meta group. The legacy path parses through `dicom-parser` with a pako inflater and handles
|
|
808
|
+
it:
|
|
809
|
+
|
|
810
|
+
```ts
|
|
811
|
+
cornerstone: { dicomImageLoader: { useLegacyMetadataProvider: true } }
|
|
812
|
+
```
|
|
813
|
+
|
|
814
|
+
That path is deprecated upstream, so treat it as a workaround for this transfer syntax rather than a
|
|
815
|
+
default.
|
|
816
|
+
|
|
817
|
+
**`Module "fs"/"path" has been externalized for browser compatibility`** during build, pointing at
|
|
818
|
+
the codec packages. Harmless — the Emscripten glue only reaches for them under Node. The equivalent
|
|
819
|
+
webpack fix is `config.resolve.fallback = { fs: false }`.
|
|
820
|
+
|
|
821
|
+
**Serving under a subpath, or from a CDN.** Point the loader at the binaries yourself:
|
|
822
|
+
|
|
823
|
+
```ts
|
|
824
|
+
cornerstone: {
|
|
825
|
+
dicomImageLoader: { wasmBasePath: '/assets/cs-wasm/' },
|
|
826
|
+
}
|
|
827
|
+
```
|
|
828
|
+
|
|
829
|
+
One root for every codec, containing `charlswasm_decode.wasm`, `libjpegturbowasm_decode.wasm`,
|
|
830
|
+
`openjpegwasm_decode.wasm` and `openjphjs.wasm`, copied from the `dist` directory of each
|
|
831
|
+
`@cornerstonejs/codec-*` package.
|
|
832
|
+
|
|
833
|
+
**`No known conditions for "./types" specifier in "@cornerstonejs/core"`** at build time. Something
|
|
834
|
+
is importing `@cornerstonejs/core/types` as a *value*; that subpath only has a `types` condition. Use
|
|
835
|
+
`import type`.
|
|
836
|
+
|
|
837
|
+
**Tool names look minified** (`LengthTool` registered as `FE`). Set `build.minify: false`, or
|
|
838
|
+
register tools by their `toolName` string.
|
|
839
|
+
|
|
840
|
+
## Scope
|
|
841
|
+
|
|
842
|
+
This release covers 2D stack viewing: initialisation, `<CornerstoneViewport>`, tool groups, the
|
|
843
|
+
[viewer composables](#building-a-viewer) that assemble them into a working viewer,
|
|
844
|
+
annotations — drawn by the user, or imported as read-only boxes — and the `wadouri:` /
|
|
845
|
+
`dicomfile:` loading paths. Imported annotations are boxes only, and are read-only: nothing writes
|
|
846
|
+
measurements back out, and DICOM SR and Presentation State are not read. Volume and MPR viewports, segmentation,
|
|
847
|
+
`@cornerstonejs/polymorphic-segmentation` and `@cornerstonejs/labelmap-interpolation` are not wired
|
|
848
|
+
up yet — the last two each need their own `optimizeDeps.exclude` entry and a `peerImport` callback.
|
|
849
|
+
|
|
850
|
+
No chrome ships either, and that is deliberate: a header, a series list and a transport are a
|
|
851
|
+
handful of buttons over the composables above, and every application wants them to look like itself.
|
|
852
|
+
Making a component library a peer dependency of a DICOM viewport would be a large tax on anyone who
|
|
853
|
+
only wants the viewport. The [playground](#playground) is the reference implementation, written to
|
|
854
|
+
be read and copied.
|
|
855
|
+
|
|
856
|
+
## Playground
|
|
857
|
+
|
|
858
|
+
```bash
|
|
859
|
+
pnpm install
|
|
860
|
+
pnpm dev:prepare
|
|
861
|
+
pnpm samples # downloads sample DICOM into playground/public/samples/
|
|
862
|
+
pnpm dev # http://localhost:3000 — or /?samples to load the stack immediately
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
The playground UI is built from PrimeVue components with Tailwind for layout. The module itself
|
|
866
|
+
ships no UI dependency and no framework requirement — `<CornerstoneViewport>` carries only the
|
|
867
|
+
rules it needs to function, and has a default slot for everything else, so the consuming
|
|
868
|
+
application supplies the look. The playground's own stylesheet names no source inside the package,
|
|
869
|
+
which is the point: what works here works in an app that has never heard of Tailwind.
|
|
870
|
+
|
|
871
|
+
What is left in `playground/app/` is therefore chrome and nothing else: the header, the series
|
|
872
|
+
list, the tool rail, the scrubber and the shortcuts dialog, plus the table of tools that the rail
|
|
873
|
+
and the keymap share. Everything underneath them — opening a study, switching series, playback,
|
|
874
|
+
prefetch, the annotation report, the keyboard — is [`useDicomStudy()` and its
|
|
875
|
+
neighbours](#building-a-viewer), and is imported from the module exactly as it would be in your own
|
|
876
|
+
application. Copying a component out of `playground/app/components/` into your project is meant to
|
|
877
|
+
work: it is ordinary PrimeVue markup over composables you already have.
|
|
878
|
+
|
|
879
|
+
The header carries an **English / فارسی** switch. It flips `<html lang dir>`, loads a Persian face,
|
|
880
|
+
and renders counts in Persian-Indic digits, while the viewport stays left-to-right. The demo's own
|
|
881
|
+
strings live in `playground/i18n/messages.ts` and are registered through
|
|
882
|
+
`cornerstone.i18n.messages`, which is the point of it: the catalogue takes arbitrary keys, so an app
|
|
883
|
+
can translate its chrome from one source without a second i18n library.
|
|
884
|
+
|
|
885
|
+
**Open annotations…** appears once there are images on screen. It reads a JSON report and draws it
|
|
886
|
+
over the stack, and the button beside it then shows and hides what it drew. A report can also be
|
|
887
|
+
dropped on the stage, on its own or alongside the study it belongs to, in which case it is drawn as
|
|
888
|
+
soon as the images finish loading. Boxes are discarded whenever the stack changes, since they are
|
|
889
|
+
keyed to the imageIds that were loaded when they were drawn.
|
|
890
|
+
|
|
891
|
+
The rail grows a **delete** section as soon as the reader has drawn something. **Click a
|
|
892
|
+
measurement to select it** — any tool selects, so this works without leaving window/level, and
|
|
893
|
+
shift-click adds to the selection — and the first button deletes what is selected, as Delete and
|
|
894
|
+
Backspace also do. Below it, an eraser clears the measurements on the image on screen and a trash
|
|
895
|
+
button clears the series, the latter asking first because it can take away work that is not in view.
|
|
896
|
+
Each button appears only when it has something to do: the eraser stays away while every measurement
|
|
897
|
+
is on the slice anyway, since it would then be the trash button under another icon, and while
|
|
898
|
+
nothing is selected the first slot holds the hint that says a measurement can be clicked at all.
|
|
899
|
+
Imported boxes are not measurements: none of this touches a report, which is shown and hidden from
|
|
900
|
+
the header and discarded with the stack.
|
|
901
|
+
|
|
902
|
+
The footer is the transport. **Play** — or the spacebar, or `C` — runs the stack as a film at the
|
|
903
|
+
rate chosen beside it, looping unless the loop button is turned off, and moving the scrubber or the
|
|
904
|
+
arrow keys takes over from it. Space is the one place this demo parts company with the OHIF keymap,
|
|
905
|
+
which resets the viewport with it; Space means play/pause everywhere else a person has used a media
|
|
906
|
+
player, so the reset moves to `R`.
|
|
907
|
+
|
|
908
|
+
Shortcuts are matched on the physical key rather than the character it types, so they work the same
|
|
909
|
+
on a Persian, Russian or French layout — `W` is the key marked W, whatever it produces. They stay
|
|
910
|
+
out of the way of anything that reads the keyboard itself: a text field, the frame-rate select, the
|
|
911
|
+
series list, the scrubber's own arrow handling, and an open dialog.
|
|
912
|
+
|
|
913
|
+
A stack **prepares itself** as soon as it has loaded — decoding every image into Cornerstone's
|
|
914
|
+
cache, in playback order, so that playback and scrolling never wait for the loader — and the footer
|
|
915
|
+
shows how far that has got. Play waits for half of it: until then the button holds a spinner rather
|
|
916
|
+
than a play icon, and the keys do nothing. Half a stack is already a comfortable head start, because
|
|
917
|
+
the film and the prefetch both run from the first image, and the rest arrives while that half plays. A prepare that stops early — cancelled by another series, or cut short by a full
|
|
918
|
+
cache — releases play whatever it managed, so the button never spins on a run that is not coming
|
|
919
|
+
back. The **Prepare** button is the way back when a run did
|
|
920
|
+
not finish, because another series interrupted it or the image cache cut it short, and it
|
|
921
|
+
disappears once there is nothing left to fetch. None of this appears for a series of a single
|
|
922
|
+
image, which has nothing to play and nothing to fetch ahead of.
|
|
923
|
+
|
|
924
|
+
The samples are the MIT-licensed test images from the Cornerstone3D repository: one CT slice in eight
|
|
925
|
+
transfer syntaxes. Loading them as a single stack exercises every decoder — pako, RLE,
|
|
926
|
+
jpeg-lossless-decoder-js, and the libjpeg-turbo, charls and openjpeg WASM codecs — so it doubles as a
|
|
927
|
+
check that the worker and WASM wiring is intact.
|
|
928
|
+
|
|
929
|
+
## Licence
|
|
930
|
+
|
|
931
|
+
MIT. Cornerstone3D is MIT, maintained by the Open Health Imaging Foundation.
|
|
932
|
+
|
|
933
|
+
**Not a medical device.** Nothing here is cleared for clinical use.
|