vectors() iteration now extracts its underlying
array once, avoiding repeated full-array extraction on R-devel. Voxel
order and returned values are unchanged; the complete vignette examples
and tests remain enabled.This release collects the changes recorded below for versions 0.14 through 0.19.1. It retains the R >= 4.3 requirement. Analyses and plotting code should account for these changes:
neuroim2::scale() generic has been
removed. Use base::scale() for ordinary matrices and
vectors, and scale_series() for neuroimaging time
series.gaussian_blur() now derives its default kernel width
from millimetre-scale sigma and voxel spacing. Use
window = 1 explicitly to reproduce the old default kernel;
use the new default for the requested smoothing width.as_canonical() now permutes and flips voxels without
interpolation or cropping. reorient() also permutes
dimensions and spacing. Previously processed images may have lost data;
regenerate them from their originals.conn_comp() now enforces
local_maxima_dist. Peak counts and coordinates may change;
rerun peak-based analyses and inspect the resulting tables.SparseNeuroVec() accepts an explicit matrix
orientation. For a square matrix returned by
series(), use
orientation = "time_x_voxels".plot_* helpers return figures visibly and
share new slice, crop, palette, threshold and layout defaults. Use
draw = TRUE to draw immediately and return invisibly. For
panel lists from plot_ortho(),
plot_edge_overlay() or plot_checkerboard(),
replace draw = FALSE with assemble = FALSE.
Recheck saved figures and downstream panel manipulation.The image(), as.raster() and
ClusteredNeuroVol-to-DenseNeuroVol coercion help topics now include
usage matching their documented arguments.
MappedNeuroVec now applies source intensity
slope/intercept exactly once, including per-volume scaling. Previously
mapped access could return stored integers while dense and file-backed
access returned decoded intensities. Re-read mapped inputs and rerun
analyses that used scaled images.
series(NeuroVecSeq, ...) now preserves time-by-voxel
orientation when the number of selected voxels equals the number of time
points in a component. Previously those square blocks were silently
transposed, also affecting voxel-group reductions. These two correctness
fixes are shared with the separate 0.20.0 development work; lazy block
iteration is not included here.
Sparse downsample(), scale_series(),
arithmetic, concat() and coercion from ROIVec
now preserve time-by-voxel orientation when the number of output voxels
equals the number of time points. The former shape heuristic silently
transposed those results. Internal sparse reads and dense-to-sparse
conversions also declare their known matrix layout explicitly, avoiding
spurious ambiguity warnings.
as.sparse() now preserves matrix dimensions when
converting a DenseNeuroVec with one time point or a mask
selecting one voxel. Numeric voxel indices and
LogicalNeuroVol masks retain the same voxel-by-time matrix
and time-by-voxel series contracts. Previously these singleton cases
errored after dropping the selected data to a vector.
Numeric masks also retain each value at its original voxel when indices are unsorted or repeated. Previously unsorted indices could silently assign values to different voxels, and repeated indices could fail a cardinality check. Numeric and equivalent logical masks now agree, including negative exclusions, zeros and empty selections. Missing, non-finite and positive out-of-bounds indices are rejected. Recompute sparse conversions made with unsorted masks.
soft_alpha_params() is now exported. It validates its
inputs, accepts an explicit knee, and returns an
alpha_floor, so the soft opacity curve can be previewed and
reused. plot_overlay() gains alpha_knee,
alpha_cap, alpha_floor,
alpha_mid, gamma_min and
gamma_max (appended to the signature), decoupling opacity
from the colour limits. The resolved curve is recorded in
attr(result, "soft_alpha"). Defaults are unchanged.
The bilateral_filter() and
bilateral_filter_4d() help now explains that
range_scale = NULL estimates the intensity bandwidth per
input, so equal intensity_sigma values are not comparable
across subjects or contrasts. For batch or group work, reuse one fixed
range_scale.
The background window of the redesigned plot functions is computed
over head voxels, so bright tissue in skull-stripped images is no longer
clipped (0% of brain voxels clipped on a skull-stripped MNI152 T1,
versus 5.4% with the previous whole-image robust quantiles).
bg_range = "data" remains available for a full-range
window.
SparseNeuroVec() documents the matrix layout convention
and accepts an explicit orientation argument
("auto", "voxels_x_time",
"time_x_voxels"). When
n_voxels == n_timepoints, auto mode keeps the historic
voxels-by-time assumption but warns; pass
orientation = "time_x_voxels" for a square
series() result so the data are not silently
transposed.
plot_overlay(), plot_montage(),
plot_ortho(), plot_edge_overlay() and
plot_checkerboard() now share one figure engine and look
like one family: borderless black tiles cropped to the head,
world-coordinate slice labels (z = -12 mm), haloed
orientation letters, and a compact fixed-width colorbar placed beside
the tiles.
>= cap when data
exceed it. Signed maps default to a new threshold-aware
"cold_hot" palette; unsigned maps to "hot".
Graded opacity modes keep every supra-threshold voxel at least 60%
opaque and fade the colorbar to match.neuro_fig, a
patchwork) that is re-arranged for the device it is drawn on, so
ggsave("f.png", plot_overlay(...), width = 6, height = 9)
gives a filled, centred layout. +, &,
| and / work, including
+ labs(title = ...).draw = TRUE prints immediately and returns
the figure invisibly. plot_ortho(),
plot_edge_overlay() and plot_checkerboard()
now return one assembled figure by default; pass
assemble = FALSE for the list of panels (previously
draw = FALSE).overlay (centred on its peak
by default).plot_edge_overlay()
thins contours, focuses on the brain, draws coinciding contours in
agree_color (NA turns this off), can compute
edges itself (compute_edges = TRUE) and has a colour key.
plot_checkerboard() quantile-matches the moving image,
interleaves tiles only inside the brain, anchors the pattern to the
visible top-left corner, and states it in a key. Both default their
labels to c("fixed", "moving").unit
("index" or "mm" for
zlevels/coord), n_slices,
annotate, canvas, interpolate and
draw (all five), and assemble (all but
plot_montage(), which returns a single faceted plot).
ncol = NULL chooses the layout that fills the canvas.style = "report" bottom legend strip is replaced by
a one-line key (plane, neurological convention, and what the threshold
shows).plot_overlay(), plot_montage(), and
plot_ortho() gain a cbar_title argument naming
the quantity drawn above the report-style colorbar. It defaults to
"value", which is what the colorbar has always shown, so
existing figures are unchanged. The new argument is appended after the
existing parameters so positional callers of legend /
crop / interpolate keep working. Set it to the
statistic actually being displayed –
cbar_title = "Semipartial r",
"Delay coefficient (% signal change)" – so a figure does
not assert a quantity it is not showing.
The style = "report" legend strip previously labeled its
swatches “Positive activation” / “Negative activation”, or “Activation”
for unsigned maps. An overlay is not necessarily a BOLD activation map –
it may be a correlation difference, a semipartial r, or a regression
coefficient – so the labels are now “Positive” / “Negative” and
“Suprathreshold”. The accompanying “higher than threshold” / “lower than
threshold” subtitles are unchanged. Figures using
style = "report" will render with the new wording.
write_vec() now writes SparseNeuroVec
images directly through the public API, preserving voxel/time ordering,
geometry, volume labels, and zeros outside the mask in .nii
and .nii.gz files (#36). Writing still expands the sparse
values into a full voxel buffer, but no explicit
DenseNeuroVec conversion is required.
searchlight_indices(mask, radius, nonzero = TRUE) now
compiles spherical searchlights directly to 1-based full-volume linear
indices. It uses the same cached, anisotropic spherical-offset and
compiled boundary-clipping machinery as the existing searchlight APIs,
but constructs no ROI objects or coordinate matrices and never changes
parallel execution state. The result records centre indices and spatial
metadata so downstream sparse-measurement builders can use the geometry
without taking on neuroim2’s analysis or execution policy.
plot() for NeuroVol and
NeuroSlice, plot_montage(),
plot_ortho(), and the overlay/registration-QC helpers now
share one affine-derived display transform. Native slice axes are
permuted and flipped so right, anterior, and superior point toward
increasing screen coordinates. This fixes axial plots whose anterior
direction was rotated toward screen-right, corrects sagittal orientation
labels from L/R to P/A, handles permuted voxel axes, and keeps oblique
native slices on a regular raster without silently shifting pixels or
resampling values.
Seven defects found by measuring the image-processing layer against
nilearn 0.14.0 / scipy, which is the reference for the
processing neuroim2 also does. The harness is in
dev/bench/nilearn/ and the findings in
dev/nilearn-gap-analysis.md.
Four of these change results; the sections that do say so explicitly.
gaussian_blur()
now applies the smoothing it is asked forwindow truncates the Gaussian at a fixed number of
voxels while sigma is in millimetres, so
the old default of window = 1 cut the kernel off at one
voxel either side regardless of how wide the kernel was meant to be.
Measured by smoothing an impulse and reading the width back out:
delivered requested shortfall
gaussian_blur(sigma = 2, window = 1) 3.49 mm 4.71 mm 26%
gaussian_blur(sigma = 3, window = 1) 3.70 mm 7.06 mm 48%
gaussian_blur(sigma = 4, window = 1) 3.76 mm 9.42 mm 60%
Worse, the shortfall depended on the voxel size –
sigma = 2, window = 1 delivered 3.49 mm FWHM on 2 mm voxels
and 1.88 mm on 1 mm voxels, so the same call smoothed two acquisitions
of the same brain differently – and nothing warned.
window now defaults to NULL, meaning
“derive the half-width from sigma and the voxel size”,
using scipy.ndimage.gaussian_filter’s rule
(int(truncate * sigma / spacing + 0.5),
truncate = 4). Requests are now honoured to better than 1%
of FWHM on isotropic and anisotropic grids alike.
window explicitly reproduces the old kernel
exactly, so old analyses remain reproducible.fwhm is accepted as an alternative to
sigma, in millimetres, which is the unit smoothing is
normally specified in. Giving both is an error.truncate controls how many standard deviations are kept
when the window is derived.as_canonical() no
longer discards dataReorienting to RAS is a permutation and a flip of the array: the
voxels are the same voxels, relabelled. It was implemented by building a
target space and handing it to resample(), and
reorient() rotated the affine while keeping the
source dimensions. When the reorientation permuted axes – which
is the whole point – the output grid did not contain the data:
in out nonzero kept
nilearn reorder_img AIR 60x72x56 -> RAS 56x60x72 100%
as_canonical (was) AIR 60x72x56 -> RAS 60x72x56 76.4%
Nearly a quarter of the image was thrown away silently. Going through a registration library also meant values were interpolated where they should merely have moved, and that images smaller than four voxels on any axis were refused outright.
as_canonical() now permutes and flips the array and
rebuilds the affine. It is exact (the output is a permutation of the
input, checked with identical()), works at any size,
matches nibabel::as_closest_canonical voxel for voxel, and
is about 40x faster. reorient() on a
NeuroSpace permutes dim and
spacing along with the affine.
conn_comp()’s
local maxima were never prunedlocal_maxima_dist had no effect at any value.
.pruneCoords() read the neighbour distances out of
dbscan::kNN()’s result as ret$distances; that
component is called dist, and $ does not
partially match a name longer than the one it is looking for, so the
lookup returned NULL, the comparison became
logical(0), and the pruning loop exited on its first pass
every time. The local_maxima table listed every in-mask
voxel – 25,538 rows for 12 components on a 3 mm map, 25,527 of them from
a single component, each one voxel from the next.
Underneath that, the rule did not deliver the minimum distance the
argument documents even when it ran. It compared each voxel only against
its single nearest neighbour, so three points at 0, 5 and 12 mm valued
5, 1 and 9 kept the 5 and the 9 at
local_maxima_dist = 15: the 5 survived because its nearest
neighbour was the 1. “The nearest neighbour” was not well defined either
– on a voxel grid more than half of all points have several neighbours
at exactly the same distance, and which one came back depended on the
kd-tree’s traversal order.
The rule is now the one the argument names: a voxel is reported when
no other voxel of its component within local_maxima_dist
has a larger value. It is deterministic and the reported maxima are at
least local_maxima_dist apart by construction.
local_maxima rows closest pair of maxima
26-connect was 25,538 3 mm (one voxel)
now 73 15.1 mm
6-connect was 25,538 3 mm (one voxel)
now 5,039 15.1 mm
conn_comp(...)$local_maxima. What it returned before was
the component’s voxel list, not its maxima.dbscan::kNN() call per component per round, which is
also what made the rest of conn_comp() worth speeding up.
dbscan is no longer used here.conn_comp_3D() was a two-pass union-find written in
interpreted R, building a 26x3 neighbour matrix per voxel and running
find() as an R closure. src/conncomp.cpp
existed but held only a commented-out sketch of it. Against
scipy.ndimage.label() it was 690x slower on a small volume
and 4,450x slower on a 1 mm brain – fifteen minutes for one labelling –
and the ratio grew with size.
scipy before after
40x48x38 (7,312 in-mask) 0.001 s 1.4 s 0.024 s
91x109x91 (90,357 in-mask) 0.016 s 38.4 s 0.31 s
182x218x182 (720,916 in-mask) 0.152 s 916 s 3.37 s
labelling alone, 91x109x91 0.016 s -- 0.027 s
The ratio to scipy no longer grows with size: it is flat at 18-22x
across a 100x range in voxel count, and the labelling itself is at
parity. What is left is the per-cluster voxel lists, the cluster table
and the local maxima that conn_comp() returns and
scipy.ndimage.label() does not;
cluster_table = FALSE, local_maxima = FALSE halves the
remainder.
The labelling is unchanged: the compiled version reproduces the R
one’s labels, sizes and size-descending numbering including its
tie-break, pinned in
tests/testthat/test-conncomp-equivalence.R against the
previous implementation, which is kept there as the reference.
conn_comp()’s wrapper was rewritten alongside it. It
re-derived a coordinate matrix per component with
as.matrix(), and then subset a data frame once per
component to build the voxel lists, which cost 0.39 s at 4,989
components against 0.03 s for the labelling.
ClusteredNeuroVol() no longer fills its cluster map with a
name-by-name list lookup, which was quadratic in the cluster count and
cost 2.2 s at 12,396 clusters.
automask() spent 92% of its time in the labeller and is
53x faster (14.0 s to 0.26 s on a 60-volume run) with
an unchanged result.
searchlight_coords(mask, radius) returned one element
per voxel in the grid where
searchlight(mask, radius) returned one per nonzero mask
voxel – 241,920 against 83,563 on an ordinary brain mask – although
both documented the latter. searchlight_coords() now
centres on nonzero mask voxels like every other iterator in the package,
and nonzero decides only what each searchlight contains, as
it does in searchlight(). It also validates its
arguments.
searchlight_coords() without nonzero = TRUE:
the iterator is now about a third as long, and no longer yields
searchlights centred outside the mask.NeuroSpace(dim, spacing, origin) builds a
positive-diagonal affine, which mirrors the usual LAS-to-RAS source.
Handing one to resample() as a target produced a near-empty
image with no warning. resample() now compares the two
world bounding boxes and warns when they barely overlap, naming the
likely cause.
as.matrix() on a DenseNeuroVec duplicated
the whole image to reshape it to voxels-by-time – 116 MB for a 60-volume
run, and over half of automask()’s runtime. A reshape is a
change of dim, not of memory, and R still copies on the
first write, so the source is untouched either way.
as.matrix() is now effectively free, mean()
goes through .rowMeans() with no reshape at all (0.21 s to
0.12 s), and scale_series() uses the same route.
resample() no longer scans the image to infer an output
datatype for headers whose geometry is all it uses.
gaussian_blur() on a NeuroVec looped its
volumes in R. Each iteration copied a volume out of the run and the
result back in, re-allocated the kernel’s two scratch buffers, and –
with a mask – recomputed the in-mask weight volume, which is the same
for every volume. Smoothing 60 volumes cost 1.66 s against nilearn’s
0.51-0.71 s.
The separable kernel now has a 4-D driver that walks the run itself:
nothing is copied per volume, the scratch buffers are allocated per
worker, the in-mask weights are computed once, and volumes run in
parallel (RcppParallel, so
RcppParallel::setThreadOptions() controls it). 0.20
s, which is 2.6-3.6x faster than nilearn rather than 2.3x
slower. The arithmetic per volume is unchanged and happens in the same
order, so the result is bit-identical to smoothing the volumes one at a
time – pinned in tests/testthat/test-processing-defects.R
over both engines, both mask conventions and both normalize
settings.
The dense kernel, which gaussian_blur() chooses when the
mask is a small fraction of the volume, has no 4-D form and still loops;
everything invariant across volumes is hoisted out of that loop.
scale(). It had no methods whatsoever, so attaching
neuroim2 masked base::scale() and broke it for
every input, not just neuroimaging objects
(scale(c(1, 2, 3)) errored with “unable to find an
inherited method”). The package itself already called
base::scale() internally to work around the mask.
scale() now resolves to base R again; use
scale_series() for the neuroimaging-specific scaling
generic (GitHub #16).The read and write paths no longer go through
readBin/writeBin. A single translation unit
(src/nifti_data_io.cpp) converts between the file’s stored
type and double in one pass, for plain and gzipped files
alike, and both read_vol()/read_vec() and
write_vol()/write_vec() now call it. On this
machine, against nibabel 5.4.2 reading the same files:
before after nibabel
read 3-D int16 .nii (14 MB) 0.46 s 0.049 s 0.027 s
read 3-D float32 .nii.gz 0.45 s 0.220 s 0.247 s
read 4-D int16 .nii (56 MB) 3.09 s 0.371 s 0.107 s
read 4-D int16 .nii.gz 2.81 s 0.750 s 0.585 s
Two things were behind the 4-D case in particular.
read_mapped_vols() built a 236 MB vector of
double indices via outer() simply to enumerate
a contiguous range, gathered it element by element through the
memory map, and handed back a time-by-voxel matrix that
DenseNeuroVec() then transposed straight back. It now reads
whole volumes sequentially and returns them voxels-by-volumes, the
layout the object stores. And building the object no longer copies the
payload: an S4 class extending array is that array
with the slots as attributes, so the read path sets them in place, the
same technique the ROI constructors already used. The equivalence to
new() is pinned by identical() in the test
suite.
read_mapped_vols() is internal, but its return layout
changed from [volumes x voxels] to
[voxels x volumes]. Two callers were also missing
scl_slope/scl_inter scaling entirely; both are
fixed.
int8, uint16, uint32,
int64 and uint64 are read and written. They
were rejected before with “Unsupported NIfTI data-type code” —
uint16 in particular is not exotic.
int32 images no longer lose voxels holding
-2147483648. That bit pattern is R’s
NA_integer_, so routing the payload through an R integer
vector turned it into NA and dropped it from every
downstream statistic; the compiled reader lands in double
and never sees an R integer.
Complex (DT_COMPLEX64/128/256), colour
(DT_RGB24, DT_RGBA32),
DT_FLOAT128 and 1-bit DT_BINARY are still not
read, but the error now names the type and says why: a
NeuroVol holds one real number per voxel, and inventing a
projection — a modulus, a luminance — would be answering a question the
caller did not ask.
as_nifti_header() built every header from defaults plus
the object’s geometry, so a read-write round trip silently discarded
everything else. It now starts from the header the image was read from.
Concretely, a round trip used to lose:
pixdim[4] reset
to 0) and xyzt_units,sform_code, rewritten from whatever it
was to 1 — so an image in MNI space came back labelled
scanner-anatomical,descrip, aux_file,
intent_code, intent_name,
cal_min/cal_max, the slice-timing fields and
toffset.All of them survive now. The source header travels on a new
header slot on NeuroObj, which every image
class inherits; header(x) reads it, and that method now
accepts images as its documentation always claimed.
write_vol()/write_vec() also default to the
source file’s datatype rather than FLOAT, when the values
are still exactly representable in it. An unmodified int16
image round-trips byte for byte instead of doubling in size; computed
data that no longer fits is promoted to FLOAT rather than
quietly quantised.
write_vol(x, f, data_type = "SHORT") went through
writeBin(as.integer(x), ...), which truncates toward zero
without a warning: data spanning ±3.7 came back with a maximum error of
0.998. scl_slope and
scl_inter are now derived to fit the target type, giving
5.4e-05 on the same data — the same precision
nibabel produces. Data that is already integral and in
range is still written unscaled, so masks, label volumes and atlases
stay exact.
Read and written, plain or gzipped.
write_vol()/write_vec() take
version = 2 or format = "NIFTI2", and select
it automatically when a dimension exceeds 32767, which NIfTI-1’s 16-bit
dim field cannot represent. Files written this way are read
correctly by nibabel, including the affine, TR and
units.
scl_slope = NaN no longer aborts the
read. It is one of the three legal spellings of “this file is
not scaled”, alongside 0 and an absent field, and it is
what nibabel’s in-memory header carries by default — so
neuroim2 was refusing to open files the reference implementation writes.
An intercept recorded next to a dead slope is dropped with it.dim[0], as the standard says, instead of being
inferred by stopping at the first extent equal to 1. A 64 × 64 × 1
single-slice volume used to load as 64 × 64, and a one-volume run as a
3-D volume.read_nifti_header() used to fold a degenerate fourth axis
and warn, so read_header() and
read_hyper_vec() could not see what the file actually
declared. The fold now happens only inside read_vec(),
where it is a documented convenience; a genuinely 5-D file is refused
there with a pointer to read_hyper_vec().qform_code == 0 and
sform_code == 0 now follow METHOD 1. Both
transforms being marked unknown means the quaternion is not a statement
about where the image is; the affine is derived from pixdim
with the image centred on the origin, which is what the standard
specifies and what nibabel returns. This also gives ANALYZE
7.5 files their correct affine instead of diag(2, 2, 2)
with a zero translation.argument is of length zero. A file that is not an image at
all now reports its leading sizeof_hdr field and what that
field must be.intent_name is read as the fixed-width byte field it
is. Reading it as a C string ran past the field whenever those 16 bytes
held no null terminator.gaussian_blur() accepts a NeuroVec and
smooths it volume by volume, with the argument checks, kernel and mask
resolved once instead of per volume.
The separable kernel also got two exact optimisations. Its
y and z passes accumulate over whole
contiguous rows and slices rather than walking a column with stride
d0 (or d0 * d1), which touched a fresh cache
line per iteration; the additions for each output element still happen
in the same order, so the result is bit-identical. And when the mask
covers every voxel — what gaussian_blur(vol) with no mask
means — the mask-insulation denominator is the zero-padded convolution
of a constant, which factorises into three per-axis edge corrections,
replacing three full-volume passes with d0 + d1 + d2
additions.
before after nibabel/scipy
3-D, FWHM 6 mm, no mask 0.60 s 0.21 s 0.13 s
4-D, FWHM 6 mm, 200 volumes 2.04 s 0.97 s 0.57 s
Equivalence to the dense kernel is checked over 432 configurations of
dimension, window, sigma, spacing, mask and normalize in
the test suite.
gaussian_blur()’s sigma is
expressed in the spatial units of the image (millimetres for typical
NIfTI data), not voxels, while window
remains a voxel half-width. Mixing the two silently under-smooths: an
FWHM converted to sigma in voxels applies roughly half the intended
smoothing with no error or warning. Added a Details section on units and
an FWHM → sigma conversion example (GitHub #23).vignette("large-data") and ?read_vec now
recommend the masked and memory-mapped paths for anything large, rather
than presenting them as a fallback for when memory runs out. For the
scattered access that searchlight, ROI and connectivity work perform
they are also faster: 5,000 voxel time courses from a 64 × 64 ×
36 × 200 run measured 44 ms with mask= and 144 ms via
mode = "mmap", against 4.8 s for loading the image and then
indexing it.as.array() on a dense image returns a plain array. It
used to hand back the payload still carrying the space,
header and class attributes, because the slots
of an S4 class extending array are attributes on
that array.linear_access() on a FileBackedNeuroVec
gathers in one vectorised step instead of looping over the request in R,
and applies data scaling, which it previously ignored.dev/bench/nibabel/ holds the conformance and timing
harnesses these numbers come from;
dev/nibabel-gap-analysis.md is the write-up. 30 probes
covering the standard now agree with nibabel on 28, with
the remaining two refused by design.R CMD check reported 7 failures and 2
errors on every platform.test-plot-registration-qc.R called
ggplot2::get_labs(), which only exists from ggplot2 3.5.2
while DESCRIPTION sets no version floor, so it errored on older ggplot2.
Tests now read plot titles through a version-agnostic helper.dev/visual-snapshots/. They compare SVG text byte-for-byte,
which encodes the font metrics of the machine that produced the
snapshot, so a single stored image cannot match the Windows, macOS and
Linux check matrix — and testthat deletes any _snaps/
directory whose test file did not run, so they could not simply be
skipped. tests/testthat/test-plot-structure.R covers the
same plotting calls with platform-independent structural assertions
(panel counts, layer counts, data shape, argument validation) and runs
on every check. See the README in dev/visual-snapshots/ for
reviewing intentional visual changes.neuroim2,
spaces-and-coordinates, volumes-and-vectors,
reading-and-writing), Do
(regions-and-searchlights,
resampling-and-orientation,
smoothing-and-filtering, visualization) and
Scale (large-data), replacing the previous
fourteen. Old article URLs redirect to their successors on the package
website; vignette() calls using the old names will need
updating.global_mask_v4.nii, which is a binary mask repeated across
four timepoints — every “time series” drawn from it was a constant
vector. Anything with a time axis now uses simulate_fmri(),
so time courses have real temporal structure and the smoothing,
searchlight and ROI examples demonstrate measurable effects.css and includes at
the document root, where html_vignette ignores them, so the
package stylesheet never reached the rendered articles. They are now
nested under the output format.Searchlight and ROI extraction are substantially faster. Each of
the three changes below was landed and verified as output-preserving on
its own — compared against a build of the previous version across 8,625
configurations of spherical_roi(),
spherical_roi_set(), series() and the four
searchlight iterators — before the correctness fixes listed under
Bug Fixes deliberately changed some of those outputs. The
speed-ups and the behaviour changes are independent.
ROI objects are no longer built with new().
ROIVolWindow reaches a basic type through two levels of
contains=, so the default initialize() walked
that chain with callNextMethod() and ran
validObject() at each level, recursing into the
NeuroSpace slot’s nested axis tree — about 450 us per
object regardless of ROI size, which was roughly 70% of an exhaustive
searchlight’s runtime. An internal constructor clones a cached prototype
and asserts the class invariants once, producing an
identical() object about 11x cheaper.
The voxel offsets of a spherical neighbourhood depend only on the
radius and the voxel spacing, so they are computed once per
(radius, spacing) pair and reused; per centre the work is a
translate-and-clip. Candidate windows are also sized per axis rather
than from min(spacing), which scanned about 3x too many
candidates on anisotropic voxels.
series() gathers in a single compiled pass.
series(DenseNeuroVec, matrix) previously looped over
timepoints, rebuilding a double index vector each pass (~17x faster);
series(AbstractSparseNeuroVec, integer) allocated a zero
matrix and scattered into it (~1.8x faster, close to
memory-bandwidth-bound). The sparse fast path is gated on the concrete
SparseNeuroVec class so subclasses that override
matricized_access() keep their hook.
The searchlight iterators do the whole per-centre job in one
compiled pass – translate the cached offset template, clip to the
volume, apply the mask, gather values, locate the centre row – with
everything invariant hoisted out of the iterator closure.
searchlight_coords() in particular used to build a full
ROIVolWindow per centre and then discard all but its
coordinates. spherical_roi_set() expands every centre in
one batched call, so searchlight(eager = TRUE) benefits
too.
The iterators remain lazy. Materialising a whole-brain radius-8 mm searchlight at once would be about 0.9 GB of coordinates, or 1.5 GB as ROI objects, so the work went into making the per-element call cheap rather than into emitting everything up front.
On a 91x109x91 volume at 2 mm with a 290,137-voxel mask and radius-8 mm searchlights, per element and projected over the whole brain:
searchlight_coords() 97.0 -> 7.7 us 28.1 s -> 2.2 s
searchlight(eager = FALSE) 92.3 -> 28.0 us 26.8 s -> 8.1 s
spherical_roi() 660.0 -> 85.0 us 191.5 s -> 24.7 sgaussian_blur() now evaluates the kernel separably
where that is cheaper. A Gaussian is a tensor product, so the full
(2 * window + 1)^3 kernel is equivalent to three 1-D passes
— 15x fewer multiplies at window = 3. The masked default
(normalize = TRUE) is separable too, as the ratio of two
separable convolutions: the mask-insulated blur is
blur(x * m) / blur(m), where m is the mask
indicator.
Which form is cheaper depends on both the window and the mask fraction — the separable passes cover the whole volume, while the dense kernel visits mask voxels only — so the two implementations are both kept and the cost is estimated per call. Measured on a 91x109x91 volume at 2 mm across mask fractions from 2% to 100%, the dispatch rule picks the slower kernel by more than 5% in 7 of 224 cases and never by more than 1.4x, and its total time is within 0.6% of always choosing the per-case winner.
For a brain-mask-shaped input (about a quarter of the bounding box) and for unmasked whole-volume smoothing:
mask 25% whole volume
window = 1 1.7x 6.8x
window = 2 2.9x 9.6x
window = 3 5.0x 17.3x
Speed-ups are larger with normalize = FALSE (3.9x / 5.5x
/ 9.4x masked, 15x / 19x / 32x unmasked), which needs half as many
passes. The whole-volume column is the case
gaussian_blur(vol) hits when no mask is supplied.
Results are unchanged to within floating-point summation order: over
3,200 configurations of dimension, spacing, sigma, window, mask shape
and normalize, the largest absolute difference from the
previous kernel was 2.6e-16, and where the two differ most in relative
terms the separable result is the closer of the two to a high-precision
reference. Code comparing blurred volumes with identical()
rather than all.equal() may see the difference. The
separable path needs working memory the dense one did not: two vectors
of prod(dim) doubles, or three plus a byte per voxel when
normalizing. Measured peak-RSS delta on a 200x200x200 volume at
window = 1, normalize = TRUE: 153 MB against
the dense path’s 77 MB at a 20% mask, 167 against 139 at 50%, and 190
against 230 at a full mask — where the separable form is the cheaper of
the two, because the dense path’s voxel-coordinate matrix costs 24 bytes
per mask voxel. The scratch is malloced rather than
allocated by R, so it is not counted against mem.maxVSize
and exhaustion surfaces as a C++ allocation failure rather than R’s
“cannot allocate vector of size”.
One input does change materially, and only there: at
window >= 23 the dense kernel’s three-axis weight
product underflows to exactly zero in the far corners, so an
Inf in the data multiplied to NaN and poisoned
the output. The separable form applies the three factors in separate
passes, none of which underflow, and propagates the Inf.
Neither answer is meaningful — the input is not finite — but they
differ.
Five scalar R loops over voxels are now vectorised. Each was verified against a reference that reproduces the loop it replaced, and against a build of the previous version across 9,163 recorded outputs.
before after
ClusteredNeuroVec [i, j, k, m] 10,000 voxels 0.194 s 0.005 s 39x
mapf(NeuroVol, Kernel) 64^3, 3x3x3 3.81 s 0.096 s 40x
64^3, 5x5x5 4.10 s 0.288 s 14x
random_searchlight() 289,148 voxels 16.8 s 3.6 s 4.7x
NeuroHyperVec [i, j, k, l, m] 64,000 voxels 0.342 s 0.195 s 1.8x
as.dense(NeuroHyperVec) 40^3 x 40 x 10 1.10 s 0.653 s 1.7x
mapf() evaluated
sum(x[cbind(ii, jj, kk)] * weights) once per centre. A
kernel tap is a fixed coordinate offset, so in linear-index terms it is
a fixed scalar offset: the convolution is now one vectorised pass per
tap — a few dozen — rather than one R iteration per centre, of which
there can be a million. Whether a tap can leave the volume at all is
decided by the extreme centres, so that is a scalar test per tap and not
a per-centre one.random_searchlight() rebuilt its list of unclaimed
voxels with remain_indices[remaining[remain_indices]] on
every iteration, which is quadratic in the mask size. It now keeps a
free list whose removals cost O(batch): survivors from the tail are
swapped into the holes left behind.as.dense() on a NeuroHyperVec built a
fresh volume-sized vector and a fresh 3-D array for every (feature,
trial) pair; it now writes the in-mask positions of all trials of a
feature in one scatter. What is left is allocating and filling the dense
result itself, which is why the speed-up is modest — that result is 195
MB for the largest case above.Breaking: random_searchlight()
returns a different set of searchlights for a given random seed.
Sampling is still uniform over the unclaimed voxels, so the distribution
of results is unchanged — over 60 seeds the number of searchlights was
466.7 +/- 8.6 before and 463.6 +/- 9.0 after, with exactly the same
voxels claimed in every run — but the realized sequence differs because
the free list is no longer held in ascending order. The first
searchlight of a run is unaffected. Analyses that recorded a seed and
expect the identical partition will need to re-run.
Breaking: mapf() now clips the
kernel at the volume boundary instead of failing there. Taps falling
outside the volume contribute nothing. Previously a coordinate past the
far face raised subscript out of bounds, and one before the
near face made the gather return a shorter vector, which then
recycled against the weights and produced a silently wrong value with
only a recycling warning. Two cases are affected: any mask
reaching within the kernel’s radius of a face, which made the masked
form unusable; and an unmasked volume thinner than twice the kernel
half-width, because the centre range hwidth:(dim - hwidth)
counts backwards when dim < 2 * hwidth and so
yields centres inside the margin rather than none — on a 10 x 3 x 10
slab with a 3x3x3 kernel, 86 voxels change. Interior results are
unchanged.
mapf()’s mask dimension check was written
!all.equal(dim(mask), dim(ovol)). all.equal()
returns a character description rather than FALSE on a
mismatch, so the guard raised invalid argument type instead
of its own message. It now reports “mask must have same dimensions as
input volume”.
ClusteredNeuroVec[i, j, k, m] now rejects voxel
coordinates outside the volume. They previously reached an
if (NA > 0) and failed with “missing value where
TRUE/FALSE needed”.
Removed radius_search_3d_nonisotropic(),
radius_search_3d_direct(),
radius_search_3d_precomputed(),
local_spheres() and kernel_filt_3d_cpp(). All
five were compiled and registered but called from nowhere in the
package; kernel_filt_3d_cpp() was in any case a 2-D filter
despite its name.
Breaking: nonzero = TRUE now drops
voxels whose value is NA or NaN, in every ROI
builder and every searchlight iterator. Previously the filter was
written vals != 0, which evaluates to NA for a
missing voxel; an NA logical subscript emits an
all-NA row, so a ROIVolWindow could come back
with NA NA NA coordinates. Missing values also made the
eager and lazy searchlight iterators disagree with each other: on a
3x3x3 volume with one NA voxel they differed on 21 of 25
searchlights. “Nonzero” cannot be established for a missing value, so it
is dropped. The rule now lives in one place, in the compiled searchlight
core.
Fixed a SIGFPE crash in index_to_grid()
for spaces whose running product of dimensions exceeds int:
index_to_grid(NeuroSpace(c(65536L, 65536L, 2L)), 1:3)
killed the R session. The stride accumulator wrapped to zero and the
next division faulted. It is now 64-bit.
searchlight() and searchlight_coords()
again reject a radius smaller than the finest voxel
dimension, as the single-ROI builders always have. The lazy iterators
had stopped validating it and returned degenerate one-voxel windows
instead.
Fixed a segfault in gaussian_blur() when
mask and vol had different dimensions. Nothing
checked that they matched, and the kernel built its membership lookup by
writing at mask_idx into a vector sized for the volume, so
an index past the end was an unbounded write.
gaussian_blur() now requires matching dimensions, and the
kernel bounds-checks the index regardless.
enhance_stat_map() gained the same dimension
check.
Fixed gaussian_blur() returning an all-zero volume
for sigma above about 1e203. The kernel carried a per-axis
sqrt(2 * pi * sigma) factor that cancelled exactly in the
normalization but cubed to Inf first, and
Inf/Inf made every weight NaN. The factor is
no longer formed, which leaves the normalized kernel unchanged for every
finite sigma.
Fixed a session-killing crash in gaussian_weights()
for window >= 645, where (2 * window + 1)^3
wrapped negative in int and allocated a vector of negative
length.
gaussian_blur() now rejects a sigma or
window that is missing, non-finite, or not length one;
previously window = Inf and sigma = Inf were
accepted and produced an all-zero volume. A window larger
than the volume is clamped to max(dim(vol)), which changes
no result — every tap that far out is out of bounds from every voxel —
and keeps absurd values out of the kernel builders.
enhance_stat_map() now validates spatial_sigma
and intensity_sigma, which were the two numeric arguments
it did not check.
Breaking: spherical_roi(),
cuboid_roi() and square_roi() now share one
definition of centroid handling, nonzero filtering and the
centre/parent bookkeeping. Each previously implemented these separately
and the three disagreed with one another; where they disagreed, at least
one was wrong.
nonzero = TRUE now means what it documents.
cuboid_roi() and square_roi() used to force
the centre voxel back into the ROI after filtering, so a “nonzero”
region could contain a zero value; they had done this only so that
parent_index could be read back off the coordinate matrix.
parent_index is now derived from the requested centroid
directly, so the workaround is gone and a zero-valued centre is dropped
like any other zero voxel.center_index is the row of the centre voxel in
coords, or NA_integer_ when the centre is not
part of the ROI. spherical_roi() used to fall back to
1L, silently pointing at an unrelated voxel, and then
computed parent_index from that voxel — so a
filtered ROI reported the wrong parent index entirely.parent_index is always the linear index of the
requested centroid in the parent space, whatever the filtering, matching
what the slot documents.>= 1, and within the volume.
cuboid_roi() and square_roi() previously
accepted out-of-bounds centroids and proceeded with a warning from
matrix().c(5.7, 5, 5) is truncated
once and the same value is used both to build the neighbourhood and to
locate the centre within it. Previously the grid was built from the
truncated value while the centre was matched against the original, so
the centre was never found.coords is now an integer matrix with no dimnames, and
rows are ordered lexicographically by (x, y, z) in every
builder. cuboid_roi() and square_roi()
returned expand.grid() output, which varies x
fastest and so was ordered the opposite way, and carried
x/y/z dimnames.Breaking: The use_cpp argument of
spherical_roi() is deprecated and ignored; the compiled
implementation is always used, and passing FALSE warns. The
use_cpp = FALSE branch was wrong in two independent ways:
it returned coordinates offset by +1 voxel on every
axis (it produced 1-based coordinates where the internal
contract is 0-based, and the caller then added 1 regardless), and it
sized its candidate cube with round() rather than
ceiling() while comparing distances exclusively at the
boundary, so it also dropped legitimate boundary voxels. Checked against
brute-force enumeration over 384 geometries — every in-bounds voxel
within the radius, computed directly — the compiled path was correct in
all 384 and the fallback wrong in 35. The offset also made
spherical_roi() throw subscript out of bounds
for centroids near the volume edge; those calls now succeed.
Breaking: Fixed reorient(), which
never returned the orientation it was asked for. It applied a transform
derived only from the target axis codes, ignoring the space’s current
orientation, and it interpreted those codes with the sense inverted, so
reorient(sp, c("R", "A", "S")) produced an LPI
space and asking for an image’s existing orientation was not a no-op.
Orientation codes are now read the way affine_to_axcodes()
reports them — naming the direction each axis increases towards
— and the transform is computed relative to the current orientation.
Code that compensated for the old inversion by requesting the opposite
codes will need updating.
Breaking: Fixed a half-voxel offset in
index_to_coord() and coord_to_index(). Both
used a 0.5-voxel shift where grid_to_coord() and
coord_to_grid() use the 1-based-to-0-based offset of 1, so
the shortcut conversions disagreed with the equivalent two-step path by
half a voxel and coord_to_index() could return a
neighbouring voxel. Voxel (1, 1, 1) now maps exactly to
origin() by either route. Raster extents produced by the
plotting helpers shift by half a voxel as a consequence, placing voxel
centres on their affine positions.
Fixed an off-by-one in world-to-index conversion on anisotropic
and large-origin grids. grid_to_index() truncates, and
NeuroSpace stores affines to 7 significant figures, so a
coordinate that is arithmetically an exact voxel centre could arrive as
2.9999985 and truncate to the voxel below — on the shipped
EPI mask the error reached 1.6e-6 grid units.
coord_to_index() now rounds to the nearest voxel centre,
and grid_to_index() snaps values within 1e-4 of an integer.
Genuinely fractional grid coordinates still truncate, matching R’s array
indexing. (The previous 0.5-voxel offset was partly masking this, since
trunc(x + 0.5) rounds; correcting the offset alone exposed
it.)
Fixed linear_access() on sparse
NeuroVec objects, which could return wrong values or error
when there were more masked voxels than time points. The sparse data
matrix is stored as [time × voxel]; the linear-index path
now indexes rows and columns in the correct order.
Fixed ROIVol arithmetic so values are aligned by
voxel index, not by the original coordinate order, and fixed sparse
Summary group methods so reductions include implicit
structural zeros. ROIVol arithmetic now documents and
enforces a same-support contract (identical space and voxel set; order
may differ); missing voxels are not treated as zero.
Hardened simulate_fmri() edge cases: zero FWHM
values now disable the corresponding smoothing step,
n_time = 1 no longer trips AR loops, tiny or constant masks
no longer produce NA heteroscedasticity fields, and scalar
arguments now receive explicit validation.
DenseNeuroVol and DenseNeuroVec subsetting now
indexes the backing array directly instead of materialising full spatial
grids via expand.grid(); ArrayLike3D builds
column-major linear indices with vector arithmetic; and
linear_access(), lookup(), and
validate_indices() use lighter bounds checks
(anyNA + range() instead of allocating large
logical vectors).NeuroVec arithmetic now uses matrix-level operations
instead of per-volume S4 dispatch, NeuroVec comparisons
work for sparse-backed inputs, and temporal mean() methods
now honor na.rm.enhance_stat_map(), a display-oriented
preprocessor for unsmoothed (“salt-and-pepper”) statistical maps. It
runs a selective median despike, an edge-preserving base smooth
(guided/bilateral/gaussian), and a signal-gated unsharp pass so true
clusters keep (or sharpen) their amplitude while the noise floor is
denoised. plot_overlay() and plot_ortho()
gained an enhance argument (TRUE,
FALSE, or a list forwarded to
enhance_stat_map()) to apply it inline.plot_overlay() now returns a
single assembled patchwork object by default
(assemble = TRUE), honoring ncol/labels and
suitable for ggsave(), with an overlay statistic colorbar
(colorbar = TRUE, threshold marked). Pass
assemble = FALSE for the previous behavior (draw a panel
grid and return the per-slice ggplot list invisibly).
patchwork is now a hard dependency.style = "report" shared across
plot_overlay(), plot_ortho(), and
plot_montage() for a consistent look (dark brain tiles on a
light card, bold/italic typography, a titled colorbar,
brain-bounding-box cropping, and a smoothed background);
plot_overlay() additionally adds a positive/negative legend
strip. In report mode these functions return a single assembled
patchwork object. The underlying features are individually toggleable
via crop and interpolate (and
legend on plot_overlay()).plot_overlay() now renders signed statistical maps
correctly by default: overlays with both positive and negative values
use a diverging palette and symmetric limits, so negatives are as
visible as positives. Choosing a sequential ov_cmap for
signed data now warns. A new ov_alpha_mode = "ramp" ramps
opacity from the threshold to the cap, and ov_cap pins the
magnitude scale.plot_overlay() gained
ov_alpha_mode = "soft": a nonlinear, self-tuning opacity
curve (alpha = clamp((|v|-lo)/(hi-lo),0,1)^gamma). The knee
lo defaults to the threshold, or — when unset — to the
median in-mask magnitude (a robust noise-floor proxy), and
gamma is auto-tuned from the value distribution so opacity
rises rapidly away from zero while the noisy bulk stays transparent.
Override the exponent with alpha_gamma.plot_overlay(), plot_ortho(), and
plot_montage() now accept explicit numeric display ranges
(e.g. ov_range = c(-6, 6), range = c(0, 1000))
in addition to "robust"/"data", for consistent
scaling across panels and subjects. The overlay color limits and
proportional-alpha denominator are now computed once over the displayed
volume rather than per slice.bilateral_filter() and
bilateral_filter_4d() now accept range_scale
so callers can fix the intensity-kernel scale across observed and null
maps instead of re-estimating it separately for each input.bilateral_filter(), internal vector bilateral
filtering, and bilateral_filter_4d() now filter each center
voxel using only in-mask, in-bounds neighbors with weight
renormalization.gaussian_blur() now insulates the blur to the mask by
default (new normalize = TRUE argument). Each in-mask
output voxel is computed from in-mask neighbors only and the kernel is
renormalized by the in-mask weight (a “smooth-in-mask” convolution,
cf. AFNI 3dBlurInMask). Previously the masked path read
out-of-mask neighbor values into the convolution and normalized by the
full kernel, so (1) out-of-mask NaN/Inf
(e.g. brain-exterior values in first-level statistic maps) silently
erased a ~window-voxel shell of the masked region, and (2)
finite exterior values (e.g. zero padding) biased in-mask edge voxels.
Out-of-mask values — finite or not — can no longer affect in-mask
outputs. Pass normalize = FALSE to restore the legacy
full-kernel behavior (GitHub #22).resolve_cmap() now resolves any palette in
grDevices::hcl.pals() (e.g. "RdBu",
"Spectral", "Reds") and the
"coolwarm" diverging alias, instead of silently returning a
viridis-like ramp. Unknown palette names now emit a warning rather than
silently returning incorrect colours. Added the internal
is_diverging_cmap() classifier.NaN outputs for
singleton or constant masks.enhance_stat_map() covering impulse
(salt-and-pepper) suppression, signal-peak preservation, noise-floor
denoising, mask restriction, all three base methods, and the
enhance plot arguments.resolve_cmap() palette resolution and
warnings, is_diverging_cmap(), numeric display ranges
across
plot_overlay()/plot_ortho()/plot_montage(),
signed-map diverging defaults, ov_alpha_mode = "ramp", and
assemble/colorbar (including
ggsave() round-trip).tools/visual-qc-plots.R, which renders labelled
PNGs of the overlay scenarios (signed diverging vs. the
sequential-palette bug, alpha modes, colorbar, and
enhance_stat_map() despeckling) over the bundled MNI
template for human visual QC.range_scale parity with the
default auto scale, singleton-mask stability, and 4D mask-normalized
filtering.plot_checkerboard() for alternating tiles from two
registered volumes, and plot_edge_overlay() for comparing
fixed and moving edge maps over a structural background.theme_neuro(style = "dark") and matching style
arguments for montage, overlay, orthogonal, checkerboard, and
edge-overlay plots.coldhot and
blue-red for signed statistical overlays.NeuroSpace grid, not just matching array dimensions.zlevels,
along, and ncol before drawing and provide
clear errors for empty or invalid layouts.title, subtitle, and caption
are now layout-level draw labels, preserving per-slice panel titles such
as z = 12.plot_overlay() now validates same-grid inputs, supports
draw = FALSE, uses consistent intensity limits across
selected slices, hides repeated background legends, and supports
symmetric overlay limits for signed maps.plot_ortho() now supports draw = FALSE,
layout-level labels, dark styling, coordinate validation, and a cleaner
no-legend three-plane layout.scale_fill_neuro() now squishes out-of-range values to
the nearest color endpoint instead of censoring them to NA,
preventing robust intensity limits from creating black/transparent holes
in high-intensity anatomy.plot_checkerboard() now accepts cmap so
registration checkerboards can use the same anatomical display palette
as surrounding QC plots.plot_edge_overlay() now keeps all-zero edge slices
transparent instead of tinting the full panel when edge limits
collapse.ggplot return values, and draw = TRUE
rendering.plot_overlay() grid validation and
no-draw return values, plot_ortho(draw = FALSE), invalid
orthogonal coordinates, scale squishing, and all-zero edge overlay
transparency.NeuroVecSeq now supports matrix conversion and dense
coercion helpers for easier interoperability with standard matrix
workflows.NeuroVec now supports optional per-volume
volume_labels() metadata across dense, sparse, mapped,
file-backed, bigvec, and NeuroVecSeq backends.vec[["label"]], with strict unique-match semantics, and
sub_vector() now accepts character label vectors.write_vec() now round-trips per-volume labels through a
custom NIfTI extension; read_vec() and low-footprint
readers restore labels on load.apply_mask() to
apply an existing 3D mask, clip_level() to estimate a
foreground clip threshold or gradual clip map, and
automask() to derive a brain-like mask from image
intensities for NeuroVol, NeuroVec, sparse,
mapped, and file-backed objects.R CMD check reported 7 failures and 2
errors on every platform.test-plot-registration-qc.R called
ggplot2::get_labs(), which only exists from ggplot2 3.5.2
while DESCRIPTION sets no version floor, so it errored on older ggplot2.
Tests now read plot titles through a version-agnostic helper.dev/visual-snapshots/. They compare SVG text byte-for-byte,
which encodes the font metrics of the machine that produced the
snapshot, so a single stored image cannot match the Windows, macOS and
Linux check matrix — and testthat deletes any _snaps/
directory whose test file did not run, so they could not simply be
skipped. tests/testthat/test-plot-structure.R covers the
same plotting calls with platform-independent structural assertions
(panel counts, layer counts, data shape, argument validation) and runs
on every check. See the README in dev/visual-snapshots/ for
reviewing intentional visual changes.read_vec(), read_vol(), and
read_image() documentation.NeuroVec label inference for lists of
NeuroVol inputs and refreshed the generated documentation
for volume_labels() and related extractors.clip_level()/automask() into
compiled code while preserving the existing median and
mean_abs semantics.clip_level() against
explicit voxelwise median and mean_abs
reference volumes, plus a regression test for integer-valued histogram
behavior.mean_abs sign-flip
invariance and backend-parity tests covering clip_level(),
automask(), and apply_mask() on dense, mapped,
and file-backed vectors.plot_overlay() no longer renders the overlay flipped
relative to the background. Previously the overlay was drawn via
grid::rasterGrob, which ignored the slice’s affine
transform, so voxel (1,1) was placed at the top-left of the
panel while the background placed it at its true world (mm) position.
Overlays are now reoriented to match the background and span the full
pixel-edge extent.plot_overlay() no longer errors with “NAs are not
allowed in subscripted assignments” when the overlay contains
NA voxels and ov_thresh > 0.plot_overlay() panel titles now use x =,
y =, or z = based on the slicing axis
(along).NeuroVec now supports optional per-volume
volume_labels() metadata across dense, sparse, mapped,
file-backed, bigvec, and NeuroVecSeq backends.vec[["label"]], with strict unique-match semantics, and
sub_vector() now accepts character label vectors.write_vec() now round-trips per-volume labels through a
custom NIfTI extension; read_vec() and low-footprint
readers restore labels on load.NeuroVecSeq, and NIfTI label
round-tripping.NeuroSpace() to derive spacing() and
origin() from the affine matrix when constructed with
trans=. Previously, spacing() returned
(1,1,1) for spaces created from an explicit affine.drop_dim() for 3D-to-2D NeuroSpace
objects to properly subset the affine matrix, preserving spatial
transforms instead of lossy reconstruction from spacing/origin.crayon with cli for all
user-facing output. The cli package provides structured
error messages, progress bars, and consistent ANSI formatting.assertthat with cli::cli_abort()
across all 253 assertion sites, providing richer error messages with
argument and class markup.vdiffr to Suggests for visual regression
testing.show() methods now use a unified formatting
style via internal
show_header()/show_rule()/show_field()
helpers.show() methods for DenseNeuroVol and
NeuroSpace (previously had no informative display).random_searchlight() and
searchlight(eager=TRUE) now display a cli
progress bar in interactive sessions.normalize_mask() internal helper consolidates
duplicated mask-coercion logic.NeuroSpace test suite (26 tests) covering
construction, coordinate transforms, affine operations, and dimension
manipulation.vdiffr plot snapshot tests (7 tests) for
plot(), plot_ortho(),
plot_montage(), and plot_overlay().make_vol(), make_vec(),
make_mask(), etc.).R CMD check reported 7 failures and 2
errors on every platform.test-plot-registration-qc.R called
ggplot2::get_labs(), which only exists from ggplot2 3.5.2
while DESCRIPTION sets no version floor, so it errored on older ggplot2.
Tests now read plot titles through a version-agnostic helper.dev/visual-snapshots/. They compare SVG text byte-for-byte,
which encodes the font metrics of the machine that produced the
snapshot, so a single stored image cannot match the Windows, macOS and
Linux check matrix — and testthat deletes any _snaps/
directory whose test file did not run, so they could not simply be
skipped. tests/testthat/test-plot-structure.R covers the
same plotting calls with platform-independent structural assertions
(panel counts, layer counts, data shape, argument validation) and runs
on every check. See the README in dev/visual-snapshots/ for
reviewing intentional visual changes.@rdname method families, reducing man
pages from 276 to 265.downsample() for DenseNeuroVol and
DenseNeuroVec so output NeuroSpace objects now
carry a correctly rescaled affine transform. Previously, voxel
dimensions could change while trans() still reflected the
pre-downsample grid.plot_overlay() gains an ov_alpha_mode
argument: "binary" (default, existing behaviour) applies a
uniform alpha to thresholded pixels, while "proportional"
scales per-pixel alpha by the absolute overlay value for smoother
blending. Internal helpers matrix_to_colors(),
matrix_to_rgba(), and matrix_to_raster_grob()
now accept an alpha_map argument to support this.R/axis.R: new
helpers .default_axcode_labels(),
.validate_ornt(), ornt_transform(),
inv_ornt_aff(), apply_orientation(),
flip_axis(), io_orientation(),
axcodes2ornt(), and ornt2axcodes() provide a
comprehensive NiBabel-compatible orientation API.ClusteredNeuroVec
vignette: searchlight values() dimensions were documented
as N x T but are actually T x N (time points x neighbors).albersdown dependency from vignette setup
chunks; all vignettes now use neuroim2::theme_neuro()
exclusively..ecosystem.yml to .Rbuildignore to
silence hidden-file NOTE.deoblique() for
NeuroSpace/NeuroVol, with
gridset/newgrid controls and default isotropic
grid spacing equal to the minimum input voxel size.
NeuroVol inputs are resampled to an axis-aligned deobliqued
target space.plot(NeuroVol) and plot(NeuroSlice)
memory blowups for oblique/sheared affines by rasterizing on pixel-grid
coordinates instead of world-coordinate grids; added regression tests
for oblique affine plotting.read_hyper_vec() reader returns NeuroHyperVec;
read_image(type = "auto") now dispatches to
NeuroHyperVec for 5D inputs (with optional spatial
masking); and write_vec() now supports
NeuroHyperVec so 5D NIfTI read/write round-trips are
supported.write_vec() affine round-trip regression: NIfTI
sform (direct affine) is now preferred over qform (quaternion-derived)
on read, matching the convention used by FSL, FreeSurfer, and ANTs.
Previously, the sform was silently replaced by the qform, causing
world-coordinate drift for vector fields and warp images.as_nifti_header() to derive qoffset
from the transform matrix translation column, ensuring internal header
consistency between quaternion parameters and the sform.NeuroSpace affine precision from 6 to 7
significant digits, matching NIfTI float32 precision and reducing
cumulative round-trip truncation.affine_to_orientation(),
orientation_transform(), apply_orientation(),
orientation_inverse_affine(),
orientation_to_axcodes(),
axcodes_to_orientation(), and
affine_to_axcodes().output_aligned_space()
(with NiBabel-compatible alias vox2out_vox()) and
slice_to_volume_affine() (alias
slice2volume()), including support for
NeuroSpace/NeuroVol inputs, >3D spatial
handling via first 3 axes, and optional zero-based indexing
compatibility.apply_affine(),
to_matvec(), from_matvec(),
append_diag(), dot_reduce(),
voxel_sizes(), obliquity(), and
rescale_affine(), with stricter validation and
center-preserving affine rescaling semantics.reorient(NeuroSpace, orient) so returned spaces
now carry the requested orientation axes (instead of preserving original
axes).sub_clusters() generic: subset a
ClusteredNeuroVol or ClusteredNeuroVec by
integer cluster ID, numeric, or character name (looked up in the label
map). Returns a new object of the same class containing only the
selected clusters.scale_series() for DenseNeuroVec is now
~10x faster by operating row-wise on the voxels-by-time matrix instead
of double-transposing through base::scale.scale_series() method for
SparseNeuroVec: scales only masked voxels in-place on the
T×K @data matrix, returns 0 (not NaN) for zero-variance
voxels, and stays sparse.as.dense() is now an identity (no-copy) for
DenseNeuroVol.+, -, *,
/, ^) now work for
ClusteredNeuroVol (with a warning that cluster structure is
not preserved) and scalar ops for DenseNeuroVol and
SparseNeuroVol.&, |) and negation
(!) now work across DenseNeuroVol,
SparseNeuroVol, and LogicalNeuroVol, returning
LogicalNeuroVol.>, <, ==,
etc.) for SparseNeuroVol and DenseNeuroVol now
correctly return LogicalNeuroVol instead of raw sparse
vectors or mistyped volumes.NiftiExtension,
NiftiExtensionList) and read_image()
convenience wrapper.Makevars.win to correctly link
TBB libraries from RcppParallel without the non-existent
-lRcppParallel flag.meta_info() helper returns a normalized list of
basic header metadata from a filename or FileMetaInfo (dim,
spacing, origin, trans, path, etc.), making 3D/4D image introspection
simpler for new users.spherical_roi() now cross-links to
spherical_roi_set(); ROI vignette shows multi-ROI
creation.as.matrix.SparseNeuroVec() implementation
(#2).resample_to() wrapper for readable interpolation
names; delegates to existing resample() methods.