--- title: Reading and writing images output: rmarkdown::html_vignette: css: albers.css includes: in_header: - albers-header.inc - albers-header.html toc: yes toc_depth: 2.0 resource_files: - albers.css - albers-fonts.css - albers.js - albers-header.inc - albers-header.html - fonts params: family: red preset: interaction vignette: | %\VignetteIndexEntry{Reading and writing images} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} if (requireNamespace("ragg", quietly = TRUE)) knitr::opts_chunk$set(dev = "ragg_png") if (requireNamespace("systemfonts", quietly = TRUE)) albersdown::albers_register_fonts() if (requireNamespace("ggplot2", quietly = TRUE) && requireNamespace("albersdown", quietly = TRUE)) ggplot2::theme_set(albersdown::theme_albers(family = params$family, preset = params$preset)) source("_common.R") ``` ```{r albers-classes, echo=FALSE, results='asis'} cat(sprintf( paste0( '' ), params$family, params$preset )) ``` ```{r load} library(neuroim2) ``` Every analysis starts and ends at a file. This article covers the readers and writers, how to inspect a header before committing to loading anything, and the one header problem that produces wrong results without producing an error. ```{r paths} mask_file <- system.file("extdata", "global_mask2.nii.gz", package = "neuroim2") series_file <- system.file("extdata", "global_mask_v4.nii", package = "neuroim2") anat_file <- system.file("extdata", "mni_downsampled.nii.gz", package = "neuroim2") ``` ## Choosing a reader | Function | Returns | Use for | |:--|:--|:--| | `read_vol()` | `NeuroVol` | a 3D image, or one volume of a 4D one | | `read_vec()` | `NeuroVec` | a 4D series | | `read_image()` | whichever fits | unknown dimensionality | | `read_vol_list()` | `NeuroVec` | many 3D files as one series | | `read_header()` | `FileMetaInfo` | metadata without the data | ```{r readers} vol <- read_vol(mask_file) vec <- read_vec(series_file) dim(vol) dim(vec) ``` Compression is handled by extension: `.nii` and `.nii.gz` both work on read and on write. `read_vol()` on a 4D file returns its first volume, and `index` picks a different one: ```{r read-index} dim(read_vol(series_file, index = 3)) ``` Use `read_image()` when you do not know what a file holds: ```{r read-image} class(read_image(series_file))[1] ``` Reading several files gives you a 4D object either way, but not the same one. `read_vol_list()` builds a single contiguous series; `read_vec()` on a vector of paths keeps the runs as separable segments: ```{r multi} class(read_vol_list(c(mask_file, mask_file)))[1] class(read_vec(c(series_file, series_file)))[1] dim(read_vec(c(series_file, series_file))) ``` Neither returns a list, despite the name — `length()` on either counts volumes. ## Looking before you load `read_header()` reads metadata only. On a large file this costs nothing and tells you what you are about to commit to: ```{r header} hdr <- read_header(mask_file) dim(hdr) hdr@data_type hdr@spacing ``` `dim()` and `trans()` work on a header, which is the quickest way to see the affine neuroim2 will actually use without loading a byte of data: ```{r header-trans} trans(hdr) ``` `spacing()`, `origin()` and `space()` are not defined for headers — read the slots (`hdr@spacing`, `hdr@origin`) instead. The full NIfTI header is a plain list in the `header` slot, so anything the slots omit is still reachable: ```{r header-slot} hdr@header$datatype hdr@header$encoding hdr@header$vox_offset ``` ## The two affines A NIfTI file can carry **two** spatial transforms: the `qform` (a rigid transform stored as a quaternion) and the `sform` (a general affine). Each has a code saying whether it is set. The standard says use the sform when `sform_code > 0`, otherwise the qform. On a well-formed file the two agree: ```{r affines-good} c(qform_code = hdr@header$qform_code, sform_code = hdr@header$sform_code) all.equal(hdr@header$qform, hdr@header$sform) ``` They do not always agree. The anatomical image shipped with this package is a real example — its qform describes 4 mm voxels and its sform describes 1 mm ones: ```{r affines-bad} anat_hdr <- read_header(anat_file) anat_hdr@header$pixdim[2:4] anat_hdr@header$qform anat_hdr@header$sform ``` Both codes are set, so the sform wins and the image reports 1 mm voxels even though `pixdim` says otherwise: ```{r affines-consequence} spacing(read_vol(anat_file)) ``` Nothing errors. The image loads and plots correctly, and every **distance, volume and ROI radius** derived from it is wrong by a factor of about four. The coordinates themselves are not scaled by four — both affines share a translation, so they agree exactly at voxel `(1, 1, 1)` and diverge by a growing offset that reaches roughly 140 mm at the far corner: ```{r affines-divergence} far <- matrix(dim(read_vol(anat_file)), nrow = 1) sform_xyz <- grid_to_coord(space(read_vol(anat_file)), far) qform_xyz <- (anat_hdr@header$qform %*% c(far - 1, 1))[1:3] rbind(sform = as.vector(sform_xyz), qform = qform_xyz) ``` **This is the failure mode to know about**, because no amount of care downstream will catch it. Check when a file first arrives: ```{r affine-check} affines_agree <- function(file) { h <- read_header(file) qc <- h@header$qform_code sc <- h@header$sform_code if (is.null(qc) || is.null(sc) || qc <= 0 || sc <= 0) return(NA) isTRUE(all.equal(h@header$qform, h@header$sform, tolerance = 1e-4)) } c(mask = affines_agree(mask_file), anat = affines_agree(anat_file)) ``` The `is.null()` guard matters: not every format carries these fields, and returning `NA` for "cannot tell" is better than erroring on an AFNI file. If a file is affected and you trust its `pixdim`, rebuild the geometry: ```{r repair} bad <- read_vol(anat_file) fixed <- NeuroVol( as.array(bad), NeuroSpace(dim(bad), spacing = anat_hdr@header$pixdim[2:4], origin = anat_hdr@header$qform[1:3, 4], axes = space(bad)@axes ) ) spacing(fixed) ``` ## Writing `write_vol()` and `write_vec()` take an object and a path. The extension decides the container and the compression; an unrecognised or absent extension gives uncompressed NIfTI. ```{r write} out_nii <- tempfile(fileext = ".nii") out_gz <- tempfile(fileext = ".nii.gz") write_vol(vol, out_nii) write_vol(vol, out_gz) c(plain = file.size(out_nii), gzipped = file.size(out_gz)) ``` That plain file is 400 kB for a binary mask, because `write_vol()` promotes to `FLOAT` by default. Pass `data_type` when you know better: ```{r datatype} out_byte <- tempfile(fileext = ".nii") write_vol(vol, out_byte, data_type = "UBYTE") c(float = file.size(out_nii), ubyte = file.size(out_byte)) read_header(out_byte)@data_type ``` Valid types are `UNKNOWN`, `BINARY`, `UBYTE`, `SHORT`, `INT`, `FLOAT` and `DOUBLE`. A round trip preserves both data and geometry either way: ```{r roundtrip} back <- read_vol(out_gz) all.equal(as.array(back), as.array(vol)) identical(trans(space(back)), trans(space(vol))) ``` 4D objects work the same way: ```{r write-vec} out_vec <- tempfile(fileext = ".nii.gz") write_vec(sub_vector(vec, 1:2), out_vec) dim(read_vec(out_vec)) ``` ### A precision surprise that is not about files Affines round to seven significant figures, and spacing and origin to six — but this happens in `NeuroSpace()`, when the object is built, not when it is written: ```{r precision} sp <- NeuroSpace(c(4L, 4L, 4L), spacing = c(1 / 3, 0.123456789012, pi)) spacing(sp) ``` No file was involved. Because both sides of a round trip get the same rounding, `identical()` on a re-read affine does succeed, as above. What you cannot rely on is an affine you constructed matching the full-precision numbers you passed in. ```{r cleanup, include = FALSE} unlink(c(out_nii, out_gz, out_byte, out_vec)) ``` ## AFNI files AFNI `HEAD`/`BRIK` pairs are read by the same functions — pass the `.HEAD` file and the matching `.BRIK` is found beside it. `read_header()` returns an `AFNIMetaInfo` whose `header` slot holds the AFNI attribute list: ```{r afni, eval = FALSE} vol <- read_vol("subject_anat+orig.HEAD") names(read_header("subject_anat+orig.HEAD")@header) ``` ## Where to go next - `vignette("spaces-and-coordinates")` — what the affine you just read means - `vignette("large-data")` — reading files too big to hold in memory - `?read_vol`, `?read_header`, `?write_vol`