Skip to content

Commit 7d0bb79

Browse files
korbinian90claude
andcommitted
feat(dicom): detect extension-less DICOM and open full series from one file
DICOM exports often have no extension (IM_0001) or a bare UID as the file name, so they were not recognized and fell through to the mesh loader. Detect DICOM by content (the Part 10 magic: a 128-byte preamble followed by "DICM") instead of relying on the extension, and load a whole series from a single clicked file. - Add isDicomData() to @niivue/react and content-sniff payloads whose name matches no known format, routing matches through the DICOM loader. - Handle multi-file input (uri: string[]) directly in loadVolume. An array URI previously threw before reaching the DICOM path, which also broke PWA folder drop. When dcm2niix returns more than one series, each opens on its own canvas. - VS Code: expand a single opened DICOM file (by extension or content, including extension-less files opened via "NiiVue: Open") to every DICOM file in its folder so the full series loads together. Fix openDcmFolder, which sent a single URI string alongside the data array. Tests: 17 new VS Code unit tests, isDicomData unit tests, and a PWA e2e covering the multi-file array path through real dcm2niix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bff2ae8 commit 7d0bb79

8 files changed

Lines changed: 674 additions & 53 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@niivue/react': minor
3+
'niivue': minor
4+
'@niivue/pwa': patch
5+
'@niivue/streamlit': patch
6+
'@niivue/jupyter': patch
7+
'@niivue/tauri': patch
8+
---
9+
10+
Detect extension-less DICOM files and open a whole series from one click.
11+
12+
DICOM exports often have no extension (IM_0001) or a bare UID as the file
13+
name (1.2.840.113619...). These are now detected by content (the DICOM
14+
Part 10 magic: a 128-byte preamble followed by "DICM") instead of relying
15+
on the extension, and a single clicked DICOM file loads its entire series.
16+
17+
- New `isDicomData()` helper exported from `@niivue/react`.
18+
- `loadVolume` content-sniffs payloads whose name matches no known format
19+
and routes matches through the DICOM loader. Multi-file input
20+
(`uri: string[]`) is now handled directly; previously an array URI threw
21+
before reaching the DICOM path, which also broke PWA folder drop.
22+
- When dcm2niix returns more than one series (a folder with multiple
23+
acquisitions), each additional series opens on its own canvas.
24+
- The VS Code extension expands a single opened DICOM file (by extension or
25+
by content, including extension-less files opened via "NiiVue: Open") to
26+
every DICOM file in its folder, so the full series loads together. The
27+
"Open DICOM Folder" path now sends a correct file array (it previously
28+
passed a single URI string alongside the data array).

apps/pwa/tests/Dicom.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,67 @@ test.describe('Loading DICOM images', () => {
4646
expect(bodyText).not.toContain('failed')
4747
})
4848

49+
test('loads an extension-less DICOM file via magic-byte detection', async ({ page }) => {
50+
// Scanner exports often have no extension (IM_0001) or a bare UID as the
51+
// name. The viewer must detect DICOM content from the "DICM" magic bytes
52+
// and route the file through the DICOM loader.
53+
await page.goto(BASE_URL)
54+
55+
const dicomPath = path.join(__dirname, '..', 'test', 'assets', 'enh.dcm')
56+
const dicomBuffer = fs.readFileSync(dicomPath)
57+
58+
const message = {
59+
type: 'addImage',
60+
body: {
61+
data: Array.from(new Uint8Array(dicomBuffer)),
62+
uri: 'IM_0001',
63+
},
64+
}
65+
66+
await page.evaluate((m) => window.postMessage(m, '*'), message)
67+
await waitForImageLoad(page)
68+
69+
const canvases = await page.$$('canvas')
70+
expect(canvases.length).toBeGreaterThanOrEqual(1)
71+
72+
await page.waitForTimeout(2000)
73+
74+
const bodyText = await page.textContent('body')
75+
expect(bodyText).not.toContain('error')
76+
expect(bodyText).not.toContain('failed')
77+
})
78+
79+
test('loads a DICOM series passed as an array of files', async ({ page }) => {
80+
// Multi-file series arrive as `uri: string[]` + `data: ArrayBuffer[]`
81+
// (VS Code series expansion, PWA folder drop). This path previously threw
82+
// because loadVolume called string methods on the array uri. dcm2niix
83+
// assembles the array into one volume per series.
84+
await page.goto(BASE_URL)
85+
86+
const dicomPath = path.join(__dirname, '..', 'test', 'assets', 'enh.dcm')
87+
const dicomBuffer = fs.readFileSync(dicomPath)
88+
89+
const message = {
90+
type: 'addImage',
91+
body: {
92+
data: [Array.from(new Uint8Array(dicomBuffer))],
93+
uri: ['enh.dcm'],
94+
},
95+
}
96+
97+
await page.evaluate((m) => window.postMessage(m, '*'), message)
98+
await waitForImageLoad(page)
99+
100+
const canvases = await page.$$('canvas')
101+
expect(canvases.length).toBeGreaterThanOrEqual(1)
102+
103+
await page.waitForTimeout(2000)
104+
105+
const bodyText = await page.textContent('body')
106+
expect(bodyText).not.toContain('error')
107+
expect(bodyText).not.toContain('failed')
108+
})
109+
49110
test('loads DICOM from URL', async ({ page }) => {
50111
await page.goto(BASE_URL)
51112

apps/vscode/src/editorProvider.ts

Lines changed: 198 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,7 @@ export class NiiVueEditorProvider implements vscode.CustomReadonlyEditorProvider
8787
if (e.type === 'ready' && !isImageSent) {
8888
isImageSent = true
8989
this.postInitSettings(panel)
90-
91-
if (uri.path.toLowerCase().endsWith('.mhd')) {
92-
await NiiVueEditorProvider.sendMhdMessage(uri, panel.webview)
93-
} else {
94-
const body = await NiiVueEditorProvider.uriToImageBody(uri, panel.webview)
95-
panel.webview.postMessage({ type: 'addImage', body })
96-
}
90+
await NiiVueEditorProvider.sendInitialImage(uri, panel.webview)
9791
}
9892
})
9993
})
@@ -203,18 +197,30 @@ export class NiiVueEditorProvider implements vscode.CustomReadonlyEditorProvider
203197
}
204198

205199
public static async openDcmFolder(folderUri: vscode.Uri, panel: vscode.WebviewPanel) {
200+
const series = await NiiVueEditorProvider.collectDicomFolder(folderUri)
201+
if (series.uris.length > 0) {
202+
panel.webview.postMessage({
203+
type: 'addImage',
204+
body: { uri: series.uris, data: series.datas },
205+
})
206+
return
207+
}
208+
// Fallback: nothing sniffed as DICOM (e.g. files that lack the Part 10
209+
// preamble). Send every file in the folder and let the loader decide.
206210
const files = await vscode.workspace.fs.readDirectory(folderUri)
207-
const fileUris = files.map((file) => vscode.Uri.joinPath(folderUri, file[0]))
211+
const fileUris = files
212+
.filter(([, fileType]) => (fileType & vscode.FileType.File) !== 0)
213+
.map(([name]) => vscode.Uri.joinPath(folderUri, name))
208214
const data = await Promise.all(
209215
fileUris.map((uri) =>
210-
vscode.workspace.fs.readFile(uri).then((data) => NiiVueEditorProvider.toArrayBuffer(data)),
216+
vscode.workspace.fs.readFile(uri).then((d) => NiiVueEditorProvider.toArrayBuffer(d)),
211217
),
212218
)
213219
panel.webview.postMessage({
214220
type: 'addImage',
215221
body: {
216-
data: data,
217-
uri: fileUris[0].toString(),
222+
data,
223+
uri: fileUris.map((u) => u.toString()),
218224
},
219225
})
220226
}
@@ -244,21 +250,58 @@ export class NiiVueEditorProvider implements vscode.CustomReadonlyEditorProvider
244250
webviewPanel.webview.onDidReceiveMessage(async (message) => {
245251
if (message.type === 'ready') {
246252
NiiVueEditorProvider.postInitSettings(webviewPanel)
247-
248-
if (document.uri.path.toLowerCase().endsWith('.mhd')) {
249-
// MHD files have detached raw data; resolve the paired .raw file.
250-
await NiiVueEditorProvider.sendMhdMessage(document.uri, webviewPanel.webview)
251-
} else {
252-
const body = await NiiVueEditorProvider.uriToImageBody(
253-
document.uri,
254-
webviewPanel.webview,
255-
)
256-
webviewPanel.webview.postMessage({ type: 'addImage', body })
257-
}
253+
await NiiVueEditorProvider.sendInitialImage(document.uri, webviewPanel.webview)
258254
}
259255
})
260256
}
261257

258+
/**
259+
* Extensions the webview can route on its own (mirrors the customEditors
260+
* selector in package.json plus formats handled by dedicated code paths).
261+
* Files matching none of these get content-sniffed for DICOM, since DICOM
262+
* exports often have no extension or a bare UID as the file name.
263+
*/
264+
private static readonly knownExtensions = [
265+
'.dcm',
266+
'.ima',
267+
'.nii',
268+
'.nii.gz',
269+
'.mih',
270+
'.mif',
271+
'.mif.gz',
272+
'.nhdr',
273+
'.nrrd',
274+
'.mhd',
275+
'.mha',
276+
'.mgh',
277+
'.mgz',
278+
'.v',
279+
'.v16',
280+
'.vmr',
281+
'.mz3',
282+
'.gii',
283+
'.mnc',
284+
'.mnc.gz',
285+
'.npy',
286+
'.npz',
287+
'.raw',
288+
]
289+
290+
static hasKnownExtension(lowerCasePath: string): boolean {
291+
return NiiVueEditorProvider.knownExtensions.some((ext) => lowerCasePath.endsWith(ext))
292+
}
293+
294+
/** DICOM Part 10 magic: a 128-byte preamble followed by "DICM". */
295+
static isDicomData(data: Uint8Array): boolean {
296+
return (
297+
data.byteLength >= 132 &&
298+
data[128] === 0x44 && // D
299+
data[129] === 0x49 && // I
300+
data[130] === 0x43 && // C
301+
data[131] === 0x4d // M
302+
)
303+
}
304+
262305
/**
263306
* Build the message body for an `addImage`/overlay payload. Prefers a
264307
* webview URI (so NiiVue can stream large volumes) but falls back to reading
@@ -267,6 +310,10 @@ export class NiiVueEditorProvider implements vscode.CustomReadonlyEditorProvider
267310
* `localResourceRoots`, or formats that NiiVue can only ingest as bytes
268311
* (`.dcm`, `.mnc`).
269312
*
313+
* Files without a recognized extension are read and sniffed for the DICOM
314+
* magic bytes; matches are shipped as binary so the webview routes them
315+
* through the DICOM loader.
316+
*
270317
* MHD files are handled separately by `sendMhdMessage` because they need
271318
* the paired `.raw` voxel file resolved alongside the header.
272319
*/
@@ -286,9 +333,138 @@ export class NiiVueEditorProvider implements vscode.CustomReadonlyEditorProvider
286333
uri: uri.toString(),
287334
}
288335
}
336+
if (!NiiVueEditorProvider.hasKnownExtension(lowerCasePath)) {
337+
const data = await vscode.workspace.fs.readFile(uri)
338+
if (NiiVueEditorProvider.isDicomData(data)) {
339+
return {
340+
data: NiiVueEditorProvider.toArrayBuffer(data),
341+
uri: uri.toString(),
342+
}
343+
}
344+
}
289345
return { uri: webview.asWebviewUri(uri).toString() }
290346
}
291347

348+
/**
349+
* Send the initial image(s) for a freshly opened panel.
350+
*
351+
* - MHD resolves its detached `.raw` voxel file.
352+
* - A DICOM file (recognized by extension or by sniffing the file's
353+
* content) expands to every DICOM file in the same folder, so a single
354+
* clicked slice loads its whole series. dcm2niix groups the slices and
355+
* splits a multi-series folder into one volume per series.
356+
* - Everything else loads as a single image.
357+
*/
358+
static async sendInitialImage(uri: vscode.Uri, webview: vscode.Webview): Promise<void> {
359+
if (uri.path.toLowerCase().endsWith('.mhd')) {
360+
await NiiVueEditorProvider.sendMhdMessage(uri, webview)
361+
return
362+
}
363+
const name = uri.path.split('/').pop() ?? ''
364+
if (NiiVueEditorProvider.isDicomCandidateName(name)) {
365+
try {
366+
const series = await NiiVueEditorProvider.collectDicomFolderImages(uri)
367+
if (series) {
368+
webview.postMessage({
369+
type: 'addImage',
370+
body: { uri: series.uris, data: series.datas },
371+
})
372+
return
373+
}
374+
} catch {
375+
// Fall through to a single-file load if directory scanning fails.
376+
}
377+
}
378+
const body = await NiiVueEditorProvider.uriToImageBody(uri, webview)
379+
webview.postMessage({ type: 'addImage', body })
380+
}
381+
382+
/**
383+
* Cheap name-based pre-filter for DICOM files, used to avoid reading every
384+
* sibling in a directory. Matches `.dcm`/`.ima`, extension-less names
385+
* (`IM_0001`), and DICOM UID-style names (digits and dots). Actual content
386+
* is still verified with `isDicomData` before a file is treated as DICOM.
387+
*/
388+
static isDicomCandidateName(name: string): boolean {
389+
const base = (name.split('/').pop() ?? name).toLowerCase()
390+
if (base.endsWith('.dcm') || base.endsWith('.ima')) {
391+
return true
392+
}
393+
return !base.includes('.') || /^[\d.]+$/.test(base)
394+
}
395+
396+
/** The parent directory of a file URI. */
397+
private static parentDir(uri: vscode.Uri): vscode.Uri {
398+
const slashIndex = uri.path.lastIndexOf('/')
399+
const parentPath = slashIndex >= 0 ? uri.path.substring(0, slashIndex) : ''
400+
return uri.with({ path: parentPath || '/' })
401+
}
402+
403+
/**
404+
* Read every DICOM file in a directory (name pre-filtered, content
405+
* verified) and return their URIs and bytes, sorted by URI for a stable
406+
* slice order.
407+
*/
408+
static async collectDicomFolder(
409+
dirUri: vscode.Uri,
410+
): Promise<{ uris: string[]; datas: ArrayBuffer[] }> {
411+
let entries: [string, vscode.FileType][]
412+
try {
413+
entries = await vscode.workspace.fs.readDirectory(dirUri)
414+
} catch {
415+
return { uris: [], datas: [] }
416+
}
417+
const collected: { uri: string; data: ArrayBuffer }[] = []
418+
for (const [name, fileType] of entries) {
419+
if ((fileType & vscode.FileType.File) === 0) {
420+
continue
421+
}
422+
if (!NiiVueEditorProvider.isDicomCandidateName(name)) {
423+
continue
424+
}
425+
const fileUri = vscode.Uri.joinPath(dirUri, name)
426+
let bytes: Uint8Array
427+
try {
428+
bytes = await vscode.workspace.fs.readFile(fileUri)
429+
} catch {
430+
continue
431+
}
432+
if (!NiiVueEditorProvider.isDicomData(bytes)) {
433+
continue
434+
}
435+
collected.push({ uri: fileUri.toString(), data: NiiVueEditorProvider.toArrayBuffer(bytes) })
436+
}
437+
collected.sort((a, b) => (a.uri < b.uri ? -1 : a.uri > b.uri ? 1 : 0))
438+
return { uris: collected.map((c) => c.uri), datas: collected.map((c) => c.data) }
439+
}
440+
441+
/**
442+
* If `fileUri` is a DICOM file, return every DICOM file in its directory so
443+
* the whole series loads together. Returns null when the file is not DICOM,
444+
* so the caller falls back to a single-file load.
445+
*/
446+
static async collectDicomFolderImages(
447+
fileUri: vscode.Uri,
448+
): Promise<{ uris: string[]; datas: ArrayBuffer[] } | null> {
449+
let clicked: Uint8Array
450+
try {
451+
clicked = await vscode.workspace.fs.readFile(fileUri)
452+
} catch {
453+
return null
454+
}
455+
if (!NiiVueEditorProvider.isDicomData(clicked)) {
456+
return null
457+
}
458+
const series = await NiiVueEditorProvider.collectDicomFolder(
459+
NiiVueEditorProvider.parentDir(fileUri),
460+
)
461+
if (series.uris.length === 0) {
462+
// Directory listing failed; load just the clicked file.
463+
return { uris: [fileUri.toString()], datas: [NiiVueEditorProvider.toArrayBuffer(clicked)] }
464+
}
465+
return series
466+
}
467+
292468
/**
293469
* Whether `uri` can be served through the webview resource proxy. Requires
294470
* the same scheme *and* authority as one of the workspace folders so that a

0 commit comments

Comments
 (0)