| Title: | Efficiently Apply Functions to Images |
|---|---|
| Description: | Infrastructure for handling generalised two and three dimensional images, which may hold multiple values at each spatial location, and efficiently applying functions to them. Dense, compacted and sparse pixel/voxel representations are supported, with one core engine for processing them all. Parallelism is supported via the 'parallel' package, 'libdispatch' and 'OpenMP'. |
| Authors: | Jon Clayden [cre, aut] (ORCID: <https://orcid.org/0000-0002-6608-0619>) |
| Maintainer: | Jon Clayden <[email protected]> |
| License: | BSD_3_clause + file LICENCE |
| Version: | 0.1.0 |
| Built: | 2026-10-16 17:47:18 UTC |
| Source: | https://github.com/jonclayden/imply |
A mask selects spatial locations, for example to restrict voxelApply() to
a region of interest. It may be given in several forms, and this function
reduces any of them to one logical value per location.
asMaskVector(mask, spatialDims)asMaskVector(mask, spatialDims)
mask |
A logical array, a numeric array in which non-zero values are selected, a sparse image whose own mask is used, or any other image, which is treated as a numeric array. |
spatialDims |
The spatial dimensions the mask must cover, or an image or imageGeometry to take them from. |
A logical vector with one element per spatial location.
A dense image is an array carrying image geometry alongside it. It is an S7
class whose parent is the array S3 class, which means every storage mode
works (double, integer, logical and complex all occur in practice, masks
being logical). The data are still a plain R array, so no copy is needed to
pass them to compiled code, and the geometry is validated whenever it is
set rather than only on construction.
denseImage( .data, voxelSize = NULL, worldTransform = NULL, spatial = NULL, unit = NULL, geometry = NULL ) isDenseImage(x) asDense(x, ...)denseImage( .data, voxelSize = NULL, worldTransform = NULL, spatial = NULL, unit = NULL, geometry = NULL ) isDenseImage(x) asDense(x, ...)
.data |
An array, or any atomic vector, which is treated as one-dimensional. |
voxelSize |
Voxel size, one per spatial dimension. |
worldTransform |
A 4x4 affine transform mapping voxel to world coordinates. Decomposed into rotation/translation and voxel size on assignment; see geometry. |
spatial |
The number of leading dimensions that index location rather than the value held at each location. Defaults to the number the geometry has, if one is given, or otherwise to three, or the dimensionality if that is smaller. |
unit |
The unit of measurement of voxel size and world coordinates. |
geometry |
An imageGeometry, or an image to take one from, giving whatever is not specified explicitly. Its grid must match the leading dimensions of the data. |
x |
An image. |
... |
Further arguments to |
Properties are stored as ordinary attributes, so compiled code reads them without needing to know anything about S7.
An object of S7 class denseImage representing a dense image, with
a geometry property holding its imageGeometry.
S7 qualifies a class name with its package, so the class
attribute is "imply::denseImage" and inherits(x, "denseImage") is
FALSE. Use isDenseImage() rather than testing the class directly.
An image geometry describes the spatial grid an image is sampled on, without any of its data: the extent of the grid, the voxel size, the placement of the grid in world space and the unit of measurement. Every image holds one, and any of the functions here may be given either an image or a bare geometry. A bare geometry is useful in its own right, to describe a space that no image has been created in yet, such as the target grid of a resampling.
imageGeometry( dims = integer(0), voxelSize = NULL, worldTransform = NULL, unit = NULL ) isImageGeometry(x) isImage(x) geometry(x) geometry(x) <- value spatial(x) voxelSize(x) voxelSize(x) <- value worldTransform(x) worldTransform(x) <- value centre(x) center(x) extent(x) sameGeometry(x, y, tolerance = sqrt(.Machine$double.eps)) toVoxel(points, x, type = "world", round = "none", bounds = NULL) fromVoxel(points, x, type = "world")imageGeometry( dims = integer(0), voxelSize = NULL, worldTransform = NULL, unit = NULL ) isImageGeometry(x) isImage(x) geometry(x) geometry(x) <- value spatial(x) voxelSize(x) voxelSize(x) <- value worldTransform(x) worldTransform(x) <- value centre(x) center(x) extent(x) sameGeometry(x, y, tolerance = sqrt(.Machine$double.eps)) toVoxel(points, x, type = "world", round = "none", bounds = NULL) fromVoxel(points, x, type = "world")
dims |
The extent of the grid, one per spatial dimension. |
voxelSize |
Voxel size, one per spatial dimension. |
worldTransform |
A 4x4 affine transform mapping (zero-based) voxel to world coordinates, from which the voxel size is also taken unless it is given separately. |
unit |
The unit of measurement of voxel size and world coordinates. |
x, y
|
Images or geometries. Anything else with a dimension attribute is
treated as having unit voxels at the origin, with the leading three
dimensions (or all of them, if fewer) spatial. |
value |
A replacement value. |
tolerance |
Numerical tolerance for comparing world transforms. |
points |
A matrix of points, one per row and one to three columns.
Missing columns are taken to be zero. Where |
type |
The coordinate convention of |
round |
Rounding strategy: |
bounds |
Optional image extents, used only by probabilistic rounding to avoid selecting a location beyond the end of the image. |
These are deliberately free of any dependency on a file format, and of any assumption that geometry is anatomical: an image with no meaningful spatial interpretation is free to leave it at the identity default and never think about it again.
Voxel size and world placement are stored, and can be set, independently of
one another. The stored placement is always a rigid transform: rotation or
reflection plus translation; no scale and no shear. worldTransform()
composes the two into the single 4x4 affine that some other packages expect.
Setting it back decomposes the matrix into rotation and scale; a matrix that
doesn't decompose that way (i.e., one with genuine shear) is rejected rather
than silently mangled.
toVoxel() and fromVoxel() use one-based voxel coordinates, matching
x[i, j, k] indexing. The affine itself, and the rest of the package's C++
API, is zero-based throughout.
imageGeometry() and geometry() return an object of S7 class
imageGeometry, with properties dims, voxelSize, orientation (the
rigid part of the world transform) and unit. isImage() and
isImageGeometry() return a Boolean value indicating whether their
argument is one of the package's image types, or a geometry,
respectively. spatial() returns the number of spatial dimensions.
voxelSize() returns a vector of sizes in each spatial dimension.
worldTransform() returns a numeric affine transform matrix. centre()
returns the world coordinates of the centre of the grid, always as three
values; center() is an alias. extent() returns the physical size of
the grid along each spatial axis. sameGeometry() returns a Boolean
value, which is TRUE if the two grids are the same size and are placed
identically in world space, up to tolerance; units are compared only if
both are known. toVoxel() and fromVoxel() return matrices of
transformed points, one per row. Voxel coordinates have as many columns as
points, and world or scaled coordinates always have three. The
assignment functions are called for their side-effects.
imapply() is a memory-efficient analogue of base::apply(). It gives the
same answer, but where apply() permutes the whole array into a fresh copy
before looping, imapply() gathers each sub-array directly through the
stride vector. Peak memory is therefore the input plus the result, rather
than twice the input plus the result, which matters once an image is larger
than a comfortable fraction of memory.
imapply(x, margin, fun, ..., simplify = TRUE, threads = NULL, progress = FALSE) voxelApply( x, fun, ..., mask = NULL, fill = 0, simplify = TRUE, threads = NULL, progress = FALSE ) lineApply( x, fun, ..., axis = 1L, simplify = TRUE, threads = NULL, progress = FALSE ) sliceApply( x, fun, ..., axis = 3L, simplify = TRUE, threads = NULL, progress = FALSE )imapply(x, margin, fun, ..., simplify = TRUE, threads = NULL, progress = FALSE) voxelApply( x, fun, ..., mask = NULL, fill = 0, simplify = TRUE, threads = NULL, progress = FALSE ) lineApply( x, fun, ..., axis = 1L, simplify = TRUE, threads = NULL, progress = FALSE ) sliceApply( x, fun, ..., axis = 3L, simplify = TRUE, threads = NULL, progress = FALSE )
x |
An image or a plain array. |
margin |
The dimensions to retain, as for |
fun |
A function to apply. |
... |
Further arguments to |
simplify |
Whether to simplify the result to an array where possible. |
threads |
Number of threads to use, or |
progress |
|
mask |
For |
fill |
The value given to locations outside |
axis |
For |
The remaining functions differ only in how much of the space they hand to
fun at a time: a single location for voxelApply(), a one-dimensional
line for lineApply(), and a two-dimensional slice for sliceApply().
In every case the values held at each location travel with the unit. So for
an image with a time series at each location, voxelApply() passes one
series, lineApply() passes a line's worth of series, and sliceApply()
passes a slice's worth:
dim(x) = 4 x 5 x 6 x 10, spatial = 3 voxelApply(x, f) f sees 10 values lineApply(x, f, axis = 1) f sees 4 x 10 sliceApply(x, f, axis = 3) f sees 4 x 5 x 10
axis always names a spatial dimension, but means what the name of each
function implies: a line runs along its axis, whereas a slice is cut
across its axis. Use imapply() directly to iterate over a non-spatial
dimension, such as applying a function to each volume in a time series.
For imapply(), as base::apply(). For voxelApply(),
lineApply() and sliceApply() applied to an image, a dense image when
the function returns a single value per location, line or slice,
respectively, and otherwise as for imapply(). The geometry of the
result is that of x with the axes traversed removed, each remaining
location being placed at the centre of the line or slice it summarises,
so centre() is unchanged. A plain array in gives a plain array out.
imreduce() computes a summary over the margins of an image without
calling back into R for each one. That matters for two reasons: there is no
per-call interpreter overhead, and (since nothing touches R) the loop can
run on worker threads. imapply() recognises the equivalent base functions
and routes calls here automatically where the answer has been shown to be
the same.
imreduce(x, margin, what, na.rm = FALSE, threads = NULL)imreduce(x, margin, what, na.rm = FALSE, threads = NULL)
x |
An image, or any array. |
margin |
The dimensions to retain, as for |
what |
The reduction: one of |
na.rm |
Whether to ignore missing values. |
threads |
Number of threads, or |
Accumulation is done in double mode whatever the values are stored as, so the answer does not depend on the storage type and error does not compound with the number of values.
Note that for the arithmetic reductions this may not agree with the base
equivalent to the last bit: R accumulates sum() and mean() in long
double, which on some platforms is wider than double. Differences are of
the order of 1e-16. imapply() therefore routes automatically only where
the answers are provably identical (min, max, range, which.min and
which.max) and leaves the rest to be asked for deliberately.
"var" is the variance of the values, as var(as.vector(x)) would give.
It is not stats::var() applied to a sub-array, which for a matrix returns
a covariance matrix.
A vector or array, shaped over margin as base::apply() would
shape it. "range" yields two values per call, so it gains a leading
dimension.
R has no single-precision floating-point type, so a large image held as
double may cost twice the memory it needs, and potentially roughly twice
the time. Raw MRI is commonly 16-bit integer.
packedImage( values, storageType, dims, slope = 1, intercept = 0, spatial = NULL, voxelSize = NULL, worldTransform = NULL, unit = NULL, geometry = NULL, layout = NULL ) isPackedImage(x) asPacked(x, type = "float32", slope = NULL, intercept = NULL, ...) storageType(x)packedImage( values, storageType, dims, slope = 1, intercept = 0, spatial = NULL, voxelSize = NULL, worldTransform = NULL, unit = NULL, geometry = NULL, layout = NULL ) isPackedImage(x) asPacked(x, type = "float32", slope = NULL, intercept = NULL, ...) storageType(x)
values |
A raw vector holding the packed values. |
dims |
The full dimensions of the image. |
slope, intercept
|
Scaling applied to stored values. Chosen automatically when not given. |
spatial, voxelSize, worldTransform, unit, geometry
|
Image geometry, as
for |
layout |
How the image's axes map onto the order of |
x |
An image or array. |
type, storageType
|
One of |
... |
Further arguments to |
Recognising this, a packed image stores its values using a narrow data type,
with optional affine scaling, so that an integer type can carry a range
it could not otherwise hold: value = stored * slope + intercept. When a
scaling is needed it is chosen automatically to map the data across the
whole of the type's range.
The values live in a raw vector, so they are garbage-collected, serialise, and survive a save and load like any other R object.
A packed image can be indexed and assigned to like an array, reading and
writing only the values concerned. Assigned values are stored under the
image's existing type and scaling, and so rounded to its resolution; a
value the scaling cannot reach, or a missing value in an integer type, is
an error, since rescaling to fit would change every other value too. Use
asDense(), or repack with asPacked(), to store such values.
An object of S7 class packedImage representing an image using a
narrow, packed data representation, with properties corresponding to the
arguments listed above.
Missing values can only be carried by float32; packing data
containing NA to an integer type is refused rather than silently losing
it. Note that NA and NaN are not distinguished once packed, since the
payload that separates them does not survive the narrowing.
Work is parallelised in one of two ways, according to what is being run.
parallelBackend() canFork() resolveThreads(threads = NULL)parallelBackend() canFork() resolveThreads(threads = NULL)
threads |
A thread count, or |
Compiled kernels run concurrently in process, using Grand Central Dispatch
or OpenMP where one or the other is available. Which of these was compiled
in is reported by parallelBackend().
An R function cannot be called from a worker thread, because R is single-threaded and its interpreter is not reentrant. Applying an R function is therefore parallelised by forking the R session instead, dividing the calls between workers. Forked workers share the image through copy-on-write, so nothing large is duplicated. Forking is unavailable on Windows, where such work runs serially; the alternative, a socket cluster, would copy the whole image to every worker and so defeat the purpose.
The number of threads may be given per call, or set globally with
options(imply.threads=n). Leaving both unset means serial, for compiled
kernels and R functions alike. Multi-core use is opt-in.
parallelBackend() returns "libdispatch", "openmp" or "none".
canFork() reports whether R functions can be run in parallel.
readImageData() reads the values of an image from a connection, as
described by a storageDescriptor(), and returns an image.
writeImageData() writes them. Neither knows anything about file formats,
which are the business of packages built on this one: a format package
parses any header or metadata, builds a descriptor and a geometry, and calls
these to handle the data.
readImageData( con, descriptor, dims, geometry = NULL, as = c("auto", "dense", "sparse", "packed"), volumes = NULL, mask = NULL ) writeImageData(image, con, descriptor)readImageData( con, descriptor, dims, geometry = NULL, as = c("auto", "dense", "sparse", "packed"), volumes = NULL, mask = NULL ) writeImageData(image, con, descriptor)
con |
A connection, or the path to a file. A connection must be open for binary reading or writing. If its position can be queried, it is moved forwards to the descriptor's offset, or when writing, padded with zeros up to it; otherwise it is taken to be already there. A path is opened, read or written from the start, and closed. |
descriptor |
|
dims |
The full dimensions of the image, in its own axis order. |
geometry |
An imageGeometry, or an image to take one from, for the spatial grid. By default the leading three dimensions (or all of them, if fewer) are spatial, with unit voxels at the origin. |
as |
The representation wanted: |
volumes |
Optionally, which volumes to read, as one-based indices into the values held at each location, taken together. The result then has the spatial dimensions followed by one dimension over the volumes read, in the order given. Only data stored a volume at a time can be read this way. |
mask |
Optionally, the spatial locations to keep, in any form
|
image |
An image or array to write. |
Bytes are read through R connections, so compressed streams need nothing
special: gzfile() reads gzipped and plain files alike. They are read in
chunks, so the only large allocation is the result itself. A gzipped stream
cannot be read backwards, so volumes are always read in the order they are
stored, whatever order they are asked for in.
The image comes back dense, packed or sparse, according to the as
argument. The default, "auto", gives a sparse image if a mask is given;
otherwise a packed image if the stored type is one a packedImage can hold,
keeping the file's narrow representation and scaling; and otherwise a dense
image. The class returned therefore depends on the data, so code that relies
on a specific class should ask for it.
A packed image keeps the stream's order, with the layout recording how its
axes map onto it, so no data is moved; see reorient(). A dense image is
always in its own axis order, so a stream in any other order is rearranged
as it is read.
A sparse image keeps exactly the locations selected by mask, if one is
given, and otherwise those where any value is non-zero. Only volume-ordered
data can be read straight into sparse form, without the dense array ever
existing; other layouts, and bit data, are read densely first.
readImageData() returns an image. writeImageData() returns,
invisibly, the descriptor as used, with any scaling it chose filled in,
which is what a format package needs to write its header.
image <- denseImage(array(1:24, c(2, 3, 4))) descriptor <- storageDescriptor("int16", endian = "big") file <- tempfile() writeImageData(image, file, descriptor) readImageData(file, descriptor, dim(image))image <- denseImage(array(1:24, c(2, 3, 4))) descriptor <- storageDescriptor("int16", endian = "big") file <- tempfile() writeImageData(image, file, descriptor) readImageData(file, descriptor, dim(image))
A sparse image stores only those spatial locations that hold data. Sparsity is over locations, not over individual values: a location is either present, in which case the whole vector of values held there is stored, or absent, in which case every one of them is implicitly zero. That generally matches the intent, and it keeps the stored values contiguous.
storedValues() returns the values exactly as stored, never copying, along
with the location of each column as a linear index into the image's own
spatial grid. The two differ only for a reoriented image.
sparseImage( mask, values, dim, spatial = NULL, voxelSize = NULL, worldTransform = NULL, unit = NULL, geometry = NULL, layout = NULL ) isSparseImage(x) asSparse(x, ...) maskedMatrix(x) storedValues(x) sparseness(x) mask(x)sparseImage( mask, values, dim, spatial = NULL, voxelSize = NULL, worldTransform = NULL, unit = NULL, geometry = NULL, layout = NULL ) isSparseImage(x) asSparse(x, ...) maskedMatrix(x) storedValues(x) sparseness(x) mask(x)
mask |
A raw vector of one bit per location, or a logical vector. |
values |
Packed values, in location order. |
dim |
The full dimensions of the image. |
spatial, voxelSize, worldTransform, unit, geometry
|
Image geometry, as
for |
layout |
How the image's axes map onto the stored order of locations,
as described for |
x |
An image or array. |
... |
Further arguments to |
The mask is one bit per location and the values are packed in location
order, so finding a value costs a table lookup and a bit count rather than a
search. A location holding NA is kept, since NA is not zero.
A sparse image can be indexed and assigned to like an array. Assignment keeps the mask accurate: a location given a non-zero value is added, and one left holding only zeros is dropped. That takes a pass over the stored values each time, which suits occasional edits; to set many values, gather them and assign once, rather than one at a time in a loop. As for an array, values are promoted to a wider replacement type.
An object of S7 class sparseImage representing a sparse image,
with properties corresponding to the arguments listed above.
A storage descriptor says how the values of an image sit in a stream of
bytes: their type, byte order and position, any scaling applied to them, and
the order of the image's axes in the stream. It deliberately says nothing
about any particular file format. A format package parses a header into a
descriptor, and readImageData() and writeImageData() do the rest.
storageDescriptor( type, endian = "little", offset = 0, slope = NULL, intercept = NULL, layout = NULL, bitOrder = "lsb" )storageDescriptor( type, endian = "little", offset = 0, slope = NULL, intercept = NULL, layout = NULL, bitOrder = "lsb" )
type |
The stored data type, as above. |
endian |
The byte order of the stream: |
offset |
The position of the first value, in bytes from the start of the stream. |
slope, intercept
|
Scaling, as |
layout |
How the image's axes map onto the order of values in the
stream, as described for |
bitOrder |
For |
Types are named by size and signedness: "int8", "uint8", "int16",
"uint16", "int32", "uint32", "int64", "float32", "float64",
"complex64" and "complex128", plus "rgb24" and "rgba32" for colour
and "bit" for one bit per value. The C-style names "char", "int",
"float" and "double" are accepted too, as "int8", "int32",
"float32" and "float64" respectively. C leaves the signedness of char
to the platform, so it is fixed as signed here; use "uint8" for unsigned
bytes.
When read, integer types of up to 32 bits become R integers unless scaled, and wider integers, floating-point types and scaled values become doubles. 64-bit integers beyond 2^53 cannot be held exactly as doubles and are refused. Colour values become integers from 0 to 255, with an extra trailing dimension of three or four channels. Bits become logical values. Scaling is not applied to complex or colour data.
An object of S7 class storageDescriptor.
storageDescriptor("int16", endian = "big", offset = 352)storageDescriptor("int16", endian = "big", offset = 352)
Every image has an axis order and direction relative to world space.
worldAxes() reports it as a signed integer for each spatial axis: which
world axis it runs along, one to three, negated if its index increases in
the negative direction. So c(-1, 2, 3) describes an image whose first
index increases along the negative first world axis, and whose second and
third increase along the second and third. The form is the same as a
storage layout's, with world axes in place of storage axes.
worldAxes(x) reorient(x, to = 1:3) storageLayout(x)worldAxes(x) reorient(x, to = 1:3) storageLayout(x)
x |
An image, or for |
to |
The world axes wanted, in the same form as |
Nothing here attaches a meaning to the world axes. Conventions that do, such as the anatomical codes used in neuroimaging, belong in packages built on this one.
reorient() permutes and reverses an image's spatial axes so that they run
along the world axes given, updating the geometry to match, so that every
voxel keeps its position in world space. Only the 48 axis-aligned
orientations are reachable this way; whatever oblique rotation is left over
stays in the geometry. For an image with fewer than three spatial
dimensions, the entries for the world axes it does not span are ignored.
For packed and sparse images this is a change of view rather than of
data: the image records how its axes map onto its storage, and indexing,
dim(), the geometry and every apply function work in the view, so no
values are moved or copied. A dense image is a plain R array, which base R
indexes in memory order, so a dense image is always stored in view order and
reorienting one copies its data.
storageLayout() returns the layout of an image: one entry per
axis, giving the one-based rank of the storage axis it corresponds to
(rank one varying fastest), negated where the image runs along that axis
backwards. It is the identity for a dense image, and for any image that
has not been reoriented or read from a file stored in another order.
worldAxes() returns an integer vector with one element per
spatial dimension, up to three. reorient() returns an image of the same
class as x, with its axes permuted and reversed as needed.
image <- denseImage(array(1:24, c(2, 3, 4)), worldTransform = diag(c(-1, 1, 1, 1))) worldAxes(image) worldAxes(reorient(image))image <- denseImage(array(1:24, c(2, 3, 4)), worldTransform = diag(c(-1, 1, 1, 1))) worldAxes(image) worldAxes(reorient(image))