Reading & Writing (I/O)
neuroimjs reads single-file NIfTI (.nii, .nii.gz; NIfTI-1 and NIfTI-2) and writes NIfTI-1. The two entry points expose different readers:
Node (neuroimjs) | Browser (neuroimjs/browser) | |
|---|---|---|
| Read a 3D volume or one frame | readVol(path | ArrayBuffer, opts?) (async) | readNiftiArrayBuffer(ArrayBuffer, opts?) (sync) |
| Read several files / a 4D run | readVolList, readVec | — |
| Header only | readHeader(path) | — |
| Write | writeVol, writeVec | — |
There are two NIfTI decoders, readVol (with readVolList and readVec, which use the same code) and readNiftiArrayBuffer. They share these rules:
- Intensity scaling. Values become
stored * scl_slope + scl_inter. Ascl_slopeof 0 or a non-finite slope means "no scaling": the stored values come back unchanged andscl_interis ignored too, as in the NIfTI-1 spec and nibabel. - Byte order. Big-endian files are byte-swapped on read.
- Geometry. The volume's
NeuroSpacecarries the full affine (space.trans).space.spacingholds the voxel sizes implied by that affine (the norms of its first three columns, as nibabel'svoxel_sizes(img.affine)computes), not the rawpixdim. The axis orientation is the one nearest the affine.
They differ in storage types:
- Scaled data become
Float32(FloatNeuroVol) inreadVolandreadVec, butFloat64(Float64NeuroVol) inreadNiftiArrayBuffer, so scaled values can differ in the last float32 digits. - Unscaled
UINT32data are promoted losslessly toFloat64NeuroVolby both decoders, because everyuint32value is exactly representable inFloat64. UnscaledUINT16data load asUInt16NeuroVolin both, and slice and display like any other datatype.
Known differences from nibabel
Reading is checked against nibabel-generated fixtures (npm run test:conformance). The affine comes from nifti-reader-js, whose transform choice differs from nibabel's in three cases (see the conformance notes and ADR-0004, which proposes a fix; its status is Proposed, so the behaviour below is what the current code does):
- when
qform_code > sform_code > 0, neuroimjs uses the qform where nibabel uses the sform; - a file with neither transform gets
diag(pixdim)with a zero offset instead of nibabel's centred base affine; - a NIfTI-2 file with only a qform fails to load.
Node: read from disk
import { readVol, readVolList, readVec } from 'neuroimjs'
// One 3D volume. The class follows the stored datatype (Int16NeuroVol, FloatNeuroVol, …);
// if scl_slope/scl_inter are set, values are scaled and returned as FloatNeuroVol.
const t1 = await readVol('sub-01_T1w.nii.gz')
// One frame of a 4D file (0-based).
const frame = await readVol('sub-01_bold.nii.gz', { index: 2 })
// Several files → NeuroVol[] (each read with the same options).
const means = await readVolList(['run-1_mean.nii.gz', 'run-2_mean.nii.gz'])
// A whole 4D file → BigNeuroVec (time-first layout; see Time Series).
const bold = await readVec('sub-01_bold.nii.gz')
// Progress callback, called with values in [0, 1].
await readVol('sub-01_T1w.nii.gz', { onProgress: p => console.log(`${Math.round(p * 100)}%`) })readVolalso accepts anArrayBufferin Node, gzipped or not. Gzip is detected from the bytes.- With a path, the file must exist, and the format is chosen from the extension. A path ending in
.nii.gzis always gunzipped. readVolListtakes an array of paths and reads them one after another. To get every frame of a single 4D file, usereadVecor loop overreadVol(path, { index }).- An unscaled
UINT32file loads asFloat64NeuroVolwithout losing integer precision; there is noUInt32NeuroVolclass. An unscaledUINT16file loads asUInt16NeuroVol. With intensity scaling active, both load asFloatNeuroVol.
4D time series: readVec
import { readVec } from 'neuroimjs'
const vec = await readVec('bold.nii.gz')
const [nT, nX, nY, nZ] = vec.dim // time first: [T, X, Y, Z]
const ts = vec.getSeries(32, 32, 20) // nT values at voxel (32, 32, 20)
const first = vec.getVolume(0) // a 3D NeuroVol with the file's geometry
const geometry = vec.volumeSpace // the file's 3D NeuroSpace, full affinereadVec decodes the file once and keeps the data in memory; it writes nothing to disk. Two things to know:
- The shape is time-first. For compatibility,
vec.dimandvec.space.dimare[T, X, Y, Z], andvec.getAt(i, j, k, t)indexes voxel(i, j, k)at timet. Becausevec.spacetreats the leading three entries as spatial, it does not describe the image geometry. Its time spacing,vec.space.spacing[0], is the header'spixdim[4](the TR) when that is positive, else 1. - Use
volumeSpacefor geometry.vec.volumeSpaceis the file's 3D space, including the full affine;getVolume(t)returns volumes on that space.
options.indices reads a subset of volumes. options.mask is accepted but currently ignored, and useBigVec no longer changes anything. See Time Series → BigNeuroVec.
Node: write
import { readVol, writeVol, readHeader } from 'neuroimjs'
const vol = await readVol('sub-01_T1w.nii.gz')
await writeVol(vol, 'out.nii') // uncompressed, datatype follows the volume (INT16 here)
await writeVol(vol, 'out.nii.gz') // gzip: compression follows the extension
await writeVol(vol, 'out_f32.nii.gz', { dataType: 'FLOAT32' })
const hdr = await readHeader('out_f32.nii.gz')
hdr.datatype // 'FLOAT32'Compression follows the file name. writeVol, writeVec and write_vol gzip a path ending in .nii.gz and leave a path ending in .nii uncompressed; the extension is matched case-insensitively. The compress and format options may repeat that choice, but they may not contradict it: { compress: false } on a .nii.gz path, or { compress: true } or format: 'NIFTI_GZ' on a .nii path, throws a NeuroimError with code INVALID_ARGUMENT and writes nothing, because the readers choose gunzip from the extension and could not open the result. For a path with any other extension, compress decides, then format: 'NIFTI_GZ'; the default is uncompressed.
writeVol options:
dataType('FLOAT32','FLOAT64','INT8','UINT8','INT16','UINT16','INT32','UINT32') converts on write. Integer targets are rounded and clamped to the type's range.onProgressreports progress.formataccepts only NIfTI names ('NIFTI','NIFTI_GZ'); anything else throwsUNSUPPORTED_FORMAT.'NIFTI', the default, leaves compression to the extension;'NIFTI_GZ'requests gzip.compressrequests (true) or refuses (false) gzip, subject to the extension rule above.
writeVol and writeVec store the full affine, rotations and flips included, as both the qform and the sform (both with code 1), and set pixdim[1..3] to the voxel sizes implied by the affine. A qform can only hold a rotation with per-axis scaling, so for a sheared affine the writers set qform_code to 0 and store the exact affine in the sform alone; neuroimjs and nibabel then read the sform. Spatial units are written as mm. The datatype follows the voxel data unless dataType is given.
writeVec(vec, path, options) writes a 4D file and takes the same options. It reads the layout from the class, never from the dimensions: a BigNeuroVec (what readVec and bigNeuroVecSeq return) is time-first and takes its geometry from volumeSpace; a DenseNeuroVec (Float32NeuroVec, Int16NeuroVec, …) or SparseNeuroVec is time-last and takes it from its 4D space. Both are written as NIfTI [X, Y, Z, T], so readVol(path, { index: t }) returns frame t either way. Any other NeuroVec implementation is rejected with INVALID_ARGUMENT. pixdim[4] is the vec's time spacing when its space has one (spacing[3] time-last, spacing[0] time-first), otherwise 1. The time unit is seconds when that spacing is known and unspecified otherwise. readVec reads pixdim[4] back as the time spacing, so writeVec(await readVec(path), out) keeps the TR. See Writing 4D data.
Node: inspect a header
import { readHeader } from 'neuroimjs'
const info = await readHeader('sub-01_T1w.nii.gz')
info.dim // [3, 197, 233, 189, 1, 1, 0, 0] — raw NIfTI dim[0..7]; dim[0] is the rank
info.dim.slice(1, 1 + info.dim[0]) // [197, 233, 189]
info.spacing // [1, 1, 1]
info.datatype // 'INT16'
info.affine // 4×4 voxel-to-world matrix (number[][])
info.sclSlope // 1
info.voxOffset // 352 — byte offset of the image datareadHeader takes a file path and is Node-only. It still reads and, for .nii.gz, decompresses the whole file. The returned HeaderInfo also has origin, bitpix, description, qformCode, sformCode and sclInter. info.spacing holds the three spatial voxel sizes. The TR is not included.
Viewer-free imports (neuroimjs/io, neuroimjs/slices, neuroimjs/geometry)
Importing the root neuroimjs entry also loads the viewer stack: pixi.js, mobx and lit. In a Node service, a CLI or an Electron main process you usually want only I/O and geometry. Three subpath exports provide those without loading any display code:
| Subpath | Exports |
|---|---|
neuroimjs/io | readVol, writeVol, readHeader, readVolList, readVec, writeVec, read_vol, write_vol, FileFormat, NIFTIFormat, NIFTIDualFormat, AFNIFormat, findDescriptor, getFormat, DenseNeuroVol, FloatNeuroVol, Float64NeuroVol, Int8NeuroVol, Int16NeuroVol, Int32NeuroVol, UInt8NeuroVol, UInt16NeuroVol, NeuroimError, NeuroimTypeError, isNeuroimError, NEUROIM_ERROR_CODES; types ReadVolOptions, WriteVolOptions, HeaderInfo, NeuroVol, NeuroVec, NeuroimErrorCode, NeuroimErrorOptions |
neuroimjs/slices | extractOrthogonalSlices, extractAxialSlice, extractSagittalSlice, extractCoronalSlice, getSliceOrientation, getWorldBoundsForSlice, extractSliceForView, getSliceAxisIndex, getMaxSliceIndex, isValidSliceIndex, getSliceAxisName, getCenterSliceIndex, getSafeSliceIndicesForSpaces, NeuroSlice; type NeuroVol |
neuroimjs/geometry | NeuroSpace, NamedAxis, AxisSet, AxisSet1D, AxisSet2D, AxisSet3D, AXIAL_LPI, CORONAL_LIP, SAGITTAL_AIL, getVolumeGeometry, assertSameVolumeGeometry, NeuroimError, NeuroimTypeError, isNeuroimError, NEUROIM_ERROR_CODES; types VolumeGeometry, NeuroimErrorCode, NeuroimErrorOptions |
At runtime each subpath works with both import (ES module build) and require (CommonJS build). Every symbol is also exported from the root entry, so you can switch an import between the two without other changes.
The type declarations are ES-module .d.ts files in a "type": "module" package. They resolve under these TypeScript settings:
moduleResolution: "NodeNext"(or"Node16") in ES-module code, and"Bundler".- CommonJS code under
module: "NodeNext"with TypeScript 5.8 or later, which allowsrequireof ES-module types. Undermodule: "Node16"or older TypeScript,import x = require('neuroimjs/io')reports TS1471. The root entry behaves the same way. - Legacy
moduleResolution: "node"/"node10", through the package'stypesVersionsmap.
Pick one module system
CommonJS and ES-module builds are separate copies of the code. If one part of a process uses require('neuroimjs/io') and another uses import 'neuroimjs/geometry', the same class has two identities: a NeuroSpace from one fails instanceof NeuroSpace against the other. Use either import or require throughout. isNeuroimError is the exception: it recognises errors from either copy.
// Electron main process: build a thumbnail without loading pixi.js
import { readVol } from 'neuroimjs/io'
import { extractOrthogonalSlices } from 'neuroimjs/slices'
const vol = await readVol('sub-01_T1w.nii.gz')
const centre = vol.space.gridToCoord(vol.dim.slice(0, 3).map((d) => (d - 1) / 2))
const { axial, sagittal, coronal } = extractOrthogonalSlices(vol, centre)readNiftiArrayBuffer is not in neuroimjs/io. It statically imports the ESM-only nifti-reader-js, which would break require('neuroimjs/io'). In Node, pass the bytes to readVol instead, which also accepts an ArrayBuffer. In the browser, readNiftiArrayBuffer is available from neuroimjs/browser. It stays synchronous and browser-only for now; loading nifti-reader-js lazily would make it asynchronous, which would change its API.
npm run test:package checks the compiled import graph of each subpath, then imports it from the packed tarball in plain Node (ESM and CommonJS). It fails if the graph reaches a display module or any package other than Node built-ins, ml-matrix, pako, nifti-reader-js and buffer, or if pixi.js, @pixi/*, mobx or lit is resolved at run time. It also type-checks the subpaths under NodeNext, Bundler and node10. These subpaths are an interim measure. A later release is planned to split the package into separate core and viewer entries; the intent is to keep these subpaths working after that split.
Browser: readNiftiArrayBuffer
The browser entry has no file system and does not export readVol. Fetch or read the bytes yourself, then decode them synchronously:
import { readNiftiArrayBuffer } from 'neuroimjs/browser'
// From a URL (.nii or .nii.gz — gzip is detected from the bytes, not the name):
const buffer = await (await fetch('/data/brain.nii.gz')).arrayBuffer()
const vol = readNiftiArrayBuffer(buffer) // synchronous; returns a NeuroVol
// One frame of a 4D file:
const frame3 = readNiftiArrayBuffer(buffer, { index: 3 })
// From a user-selected file:
const input = document.querySelector<HTMLInputElement>('input[type=file]')!
input.addEventListener('change', async () => {
const file = input.files?.[0]
if (!file) return
const picked = readNiftiArrayBuffer(await file.arrayBuffer())
console.log(file.name, picked.space.dim)
})readNiftiArrayBuffer is exported only from neuroimjs/browser. It is not in the main neuroimjs entry, because nifti-reader-js is ESM-only and a static import would break require('neuroimjs'). It returns the stored datatype (Int16NeuroVol, FloatNeuroVol, …). Scaled data come back as Float64NeuroVol, and UINT32 is promoted to Float64NeuroVol. UINT16 stays a UInt16NeuroVol. It throws on invalid input, an out-of-range index, or truncated image data. It has no dependency on Node's fs, path or Buffer. To window the result for display, see the loader in Getting Started. To check that an overlay sits on the same grid as its template before drawing it, use getVolumeGeometry and assertSameVolumeGeometry (Volumes & Slices); overlays on a different grid are placed by world position (Multi-Layer Alignment).
Formats
| Format | Read | Write |
|---|---|---|
NIfTI-1 single file (.nii, .nii.gz) | ✅ | ✅ |
| NIfTI-2 single file | ✅ header parsing via nifti-reader-js | ❌ writers always produce NIfTI-1 (n+1) |
NIfTI dual file (.hdr + .img) | ❌ recognized by findDescriptor, but the readers expect a single-file image | ❌ |
AFNI (.HEAD/.BRIK) | ❌ recognized by findDescriptor only | ❌ |
The format descriptors are exported for code that needs to branch on file type:
import { findDescriptor, getFormat, NIFTIFormat } from 'neuroimjs'
// Match an existing file to a descriptor (null if nothing matches or the file is missing).
const fmt = await findDescriptor('sub-01_T1w.nii.gz')
fmt instanceof NIFTIFormat // true
fmt?.fileFormat // 'NIFTI'
fmt?.headerEncoding // 'gzip'
// Look one up by name: NIFTI, NIFTI_GZ, NIFTI_DUAL, NIFTI_DUAL_GZ, AFNI.
getFormat('NIFTI_GZ').headerExtension // 'nii.gz'Each FileFormat records its header and data extensions and encodings, and its fileMatches(path) checks that the header and data files exist on disk. The descriptors do no decoding.
Legacy aliases
read_vol(input) and write_vol(vol, path) are thin snake_case wrappers kept for backward compatibility. read_vol is readVol with default options. write_vol is writeVol with no options, so it gzips a .nii.gz path and not a .nii path. Prefer readVol / writeVol in new code.
Errors
The library's own errors are instances of NeuroimError. Each carries a stable code, and some also carry a details object (for example { index, volumeCount }) and a cause. Branch on the code rather than on the message: message wording may change between releases, but codes will not.
import { readVol, isNeuroimError } from 'neuroimjs'
try {
const vol = await readVol(buffer, { index: 3 })
} catch (error) {
if (isNeuroimError(error, 'OUT_OF_RANGE')) {
console.warn(`only ${error.details?.volumeCount} volumes`)
} else if (isNeuroimError(error, 'CORRUPT_FILE')) {
console.warn('not a readable NIfTI file')
} else {
throw error
}
}isNeuroimError also recognises errors from another copy of the library, such as the CommonJS and ESM builds loaded side by side, where instanceof fails. NeuroimError, NeuroimTypeError and isNeuroimError are exported from both neuroimjs and neuroimjs/browser. The deprecated ValueError, IOError and NotImplementedError are exported from neuroimjs only.
An error that crosses a worker boundary (postMessage, structuredClone) arrives as a plain Error: the clone drops code, details and the brand that isNeuroimError checks, so send { code, message } explicitly if the other side needs to branch on it.
| Code | Meaning | Typical sources |
|---|---|---|
INVALID_ARGUMENT | An argument has the wrong shape, type or value | NeuroSpace with non-positive spacing or a singular affine; unknown axis names; empty readVec selection |
OUT_OF_RANGE | An index or coordinate is outside the valid range | readVol/readVec/readNiftiArrayBuffer volume index past the end; extractSliceNeuroSpace past an axis |
GEOMETRY_MISMATCH | Spaces, volumes or axis sets that must agree do not | assertSameVolumeGeometry; withDimensions with a different spatial rank; incompatible axis permutations |
UNSUPPORTED_FORMAT | The format, or a valid feature of it, is not supported | unknown file extension; writing a non-NIfTI format; a NIfTI that is neither 3D nor 4D |
UNSUPPORTED_DATATYPE | The voxel data type is not supported | NIfTI datatypes such as FLOAT128 or RGB24 |
CORRUPT_FILE | The bytes do not decode as the expected format | bad magic number, unreadable header, invalid dimensions, truncated image data |
NOT_IMPLEMENTED | The API exists but has no implementation for this input | reorient on a space that is not 2D or 3D; NotImplementedError |
IO_ERROR | Reading from or writing to storage failed | IOError |
Errors raised by Node itself, such as ENOENT for a missing file, and by third-party decoders, such as a failed gunzip, propagate unchanged and are not NeuroimErrors.
Deprecated names. ValueError is now a subclass of NeuroimError. Functions that used to throw ValueError still do, now with a specific code, so existing instanceof ValueError checks keep working. New code should use isNeuroimError instead. The old TypeError export, which shadowed the global TypeError, has been removed. Its replacement is NeuroimTypeError, which has code INVALID_ARGUMENT.
See also
- Volumes & Slices: the volume classes these readers return.
- Time Series: 4D containers, including streaming and in-place access to large runs.