The imply package provides data structures for
generalised images – two- or three-dimensional rasters that may
hold a vector or time series at each location rather than a single
intensity — together with infrastructure for applying functions to them
in a memory-efficient and optionally parallelised fashion. The primary
use case is medical imaging, but nothing in the package assumes this
explicitly. The name is a portmanteau of “image” and “apply”, and
follows in the footsteps of influential packages like
plyr.
This is a general infrastructure package that has no dependency on any specific file format, although there is support for image geometry characteristics such as pixel/voxel sizes and transforms to represent a real-world spatial embedding. Reading, writing and interoperating with other image classes is largely out of scope, and left to packages that sit above this one.
Key features include
Rcpp and
RcppArray for the C++ back-end and S7 for
modern R class types;imapply()
function and base::apply(), but with significantly less
peak memory usage, which is particularly important for large
images;parallel package,
Grand Central Dispatch or OpenMP, plus progress reporting; andThe package is in an early stage of development, and its interface
may change in subsequent releases. The latest version of the package can
be installed from GitHub using the remotes package:
## install.packages("remotes")
remotes::install_github("jonclayden/imply")imply supports three storage representations:
| class | storage | good for | |
|---|---|---|---|
| dense | denseImage |
an ordinary R array, any storage mode | general use; anything not covered below |
| sparse | sparseImage |
a bit mask over spatial locations, plus values packed in location order | images that are mostly zero outside a mask, such as data masked to a region of interest |
| packed | packedImage |
a raw vector reinterpreted as a narrower integer or floating-point type than R usually supports | large images where the values are not all needed as
double at once |
The back-end allows functions applied to images to target all of these image forms efficiently without specialisation for each type.
A denseImage wraps an array with some geometry attached:
how many of the leading dimensions are spatial, the size of a voxel, and
the 4x4 affine matrix mapping voxel to world coordinates. The latter can
be ignored where it isn’t important.
library(imply)
data <- array(rnorm(4 * 5 * 6 * 10), dim = c(4, 5, 6, 10))
image <- denseImage(data, voxelSize = c(2, 2, 2.5), spatial = 3)
image
## Dense image: 4 x 5 x 6 x 10 (double)
## Spatial dimensions : 4 x 5 x 6
## Voxel size : 2 x 2 x 2.5 (unit unknown)
## Values per location: 10Here the image is 4 x 5 x 6 spatially, with a ten-point time series
at each location — spatial = 3 says so explicitly, though
it would have been inferred anyway, since spatial()
defaults to the first three dimensions or however many there are,
whichever is fewer.
dim(image)
## [1] 4 5 6 10
spatial(image)
## [1] 3
voxelSize(image)
## [1] 2.0 2.0 2.5
worldTransform(image)
## [,1] [,2] [,3] [,4]
## [1,] 2 0 0.0 0
## [2,] 0 2 0.0 0
## [3,] 0 0 2.5 0
## [4,] 0 0 0.0 1Voxel size and world placement are stored, and can be set,
independently of one another, so replacing one never has to touch the
other — see ?geometry. fromVoxel() and
toVoxel() convert points between voxel and world space
using the composed transform, using the usual R one-based coordinates
for voxels.
fromVoxel(c(1, 1, 1), image)
## [,1] [,2] [,3]
## [1,] 0 0 0
toVoxel(fromVoxel(c(1, 1, 1), image), image)
## [,1] [,2] [,3]
## [1,] 1 1 1Because a denseImage is a plain array underneath,
ordinary R operations — indexing, arithmetic, comparison — work on it
directly and return a plain array, since an arbitrary index or
elementwise result has no well-defined geometry of its own.
asSparse(), asDense() and
asPacked() convert freely between the three
representations; asDense() also promotes a plain array to a
denseImage, without touching data already in one.
sparse <- asSparse(image > 2)
sparse
## Sparse image: 4 x 5 x 6 x 10 (logical)
## Spatial dimensions : 4 x 5 x 6
## Voxel size : 1 x 1 x 1 (unit unknown)
## Values per location: 10
## Locations stored : 29 of 120 (75.8% sparse)
sparseness(sparse)
## [1] 0.7583333A sparse image’s mask can be recovered as a logical array with
mask(), and its stored values as a matrix with one column
per present location with maskedMatrix(). Because a
sparseImage stores its values already packed that way, this
does not involve a copy.
mask(sparse)[1:2, 1:2, 1]
## [,1] [,2]
## [1,] TRUE TRUE
## [2,] FALSE TRUE
dim(maskedMatrix(sparse))
## [1] 10 29asPacked() narrows storage to a narrow integer or
float32 type, choosing a slope and intercept automatically
so the type’s whole range is used.
packed <- asPacked(image, type = "int16")
packed
## Packed image: 4 x 5 x 6 x 10 (int16)
## Spatial dimensions : 4 x 5 x 6
## Voxel size : 2 x 2 x 2.5 (unit unknown)
## Values per location: 10
## Scaling : value = stored * 0.000107171 + 0.298598
## Storage : 2,400 bytes, against 9,600 as doubleasDense() unpacks whichever compact representation it is
given, so code that just wants ordinary values need not ask which one it
has:
unpacked <- asDense(packed)
range(as.array(unpacked) - as.array(image)) # narrowing loses some precision, but very little
## [1] -5.334240e-05 5.350487e-05storageType() reports "double",
"integer" and so on for anything that is not a packed
image, and the packed type otherwise, so it can be used without first
checking which kind of image is in hand. isDenseImage(),
isSparseImage() and isPackedImage() (and the
umbrella isImage()) are the best way to test for image
types.
storageType(image)
## [1] "double"
storageType(packed)
## [1] "int16"
isPackedImage(packed)
## [1] TRUEA template argument, accepted by all three constructors
and by
asDense()/asSparse()/asPacked(),
carries geometry over from an existing image when only the data is
changing.
doubled <- denseImage(as.array(image) * 2, template = image)
voxelSize(doubled)
## [1] 2.0 2.0 2.5imapply() is the memory-efficient analogue of
apply() from the base package: it gives the
same answer for the same margin, but gathers each sub-array
directly through the image’s strides rather than permuting the whole
array into a fresh copy first.
totals <- imapply(image, 4, sum)
totals
## [1] 13.1653065 -7.9258345 11.3782881 -6.8365127 -2.9111387 -18.8599395
## [7] -0.4747438 7.4987982 -12.3572488 -10.5496616Three further verbs differ only in how much of the space is
handed to the function at a time — a single location, a line, or a slice
— with the values held at each location always travelling along with it.
For the image above with dimensions 4 x 5 x 6 x 10 (the first three
spatial), voxelApply() calls the function with 10 elements
each time, lineApply(... axis = 1) passes a 4 x 10 matrix
and sliceApply(... axis = 3) passes a 4 x 5 x 10 array.
means <- voxelApply(image, mean)
means # a single value per location comes back as an image, with the same geometry
## Dense image: 4 x 5 x 6 (double)
## Spatial dimensions : 4 x 5 x 6
## Voxel size : 2 x 2 x 2.5 (unit unknown)
lineMeans <- lineApply(image, rowMeans, axis = 1)
dim(lineMeans)
## [1] 4 5 6
sliceMeans <- sliceApply(image, mean, axis = 3)
sliceMeans
## [1] -0.028966835 -0.074629673 0.013742921 -0.078449109 -0.006249744
## [6] 0.035189006voxelApply() accepts a mask – a logical
array, a sparse image, or any numeric array where non-zero elements are
selected — and visits only those locations, leaving fill
(0 by default) everywhere else. The loop runs over a packed
matrix of just the selected columns.
masked <- voxelApply(image, mean, mask = sparse)
masked[1:2, 1:2, 1]
## [,1] [,2]
## [1,] -0.1243711 0.05416769
## [2,] 0.0000000 -0.17414455For a long-running call, progress = TRUE draws a text
bar showing percentage complete and throughput in voxels per second.
Alternatively, passing a function with arguments done and
total allows you to report however you like.
voxelApply(image, slow_function, progress = TRUE)
## |====================== | 62% 1.4M voxels/sA handful of summary functions — min, max,
range, which.min, which.max, plus
sum, mean, prod,
var, sd, any, all
and countNA — can be computed without calling back into R
at all, using imreduce():
imreduce(image, 4, "range")
## [,1] [,2] [,3] [,4] [,5] [,6] [,7]
## [1,] -2.214700 -2.888921 -2.403096 -3.008049 -2.528501 -2.939774 -2.596111
## [2,] 2.401618 2.497662 2.649167 2.165369 3.810277 2.675741 2.349493
## [,8] [,9] [,10]
## [1,] -2.402231 -2.996949 -3.213189
## [2,] 3.055742 2.401222 2.185438imapply() recognises calls to the base equivalents of
min, max, range,
which.min and which.max, and routes to
imreduce() automatically, since these are the only ones
guaranteed to give the same answer either way. Arithmetic reductions
like sum() can differ from
imapply(x, margin, sum) very slightly, because R
accumulates sum() and mean() using the
long double type internally.
identical(imapply(image, 4, max), imreduce(image, 4, "max"))
## [1] TRUEWork is parallelised in one of two ways, according to what is being
run. Compiled reductions run concurrently in-process, using Grand
Central Dispatch or OpenMP where available. The
parallelBackend() function identifies which is in use in
the current build.
An R function passed to imapply() and its variants
cannot run on a worker thread — R’s interpreter is not reentrant — so
that work is instead parallelised by forking the R session using the
parallel package, which is unavailable on Windows
(canFork() reports whether it is available). Pass a
threads argument to any apply or reduce call, or set it
globally via the imply.threads option:
options(imply.threads = 4)The apply/reduce engine, the accessors that let one kernel body read
dense, sparse and narrowly-stored images alike, and the voxel-to-world
geometry machinery behind worldTransform() and friends, are
all available to other packages’ compiled code. Everything under
inst/include/ is header-only, so using it costs nothing to
link against. The C++ headers include comments explaining their purpose
and usage.
LinkingTo: imply
in a package’s DESCRIPTION is enough; there is no
library to link, and only the one header to include.
#include "imply.h"The pieces most likely to be wanted from outside are as follows.
Raster<D> — an
n-dimensional index space with general strides, split at a
runtime spatial index into leading spatial dimensions and
trailing value dimensions. FixedRaster<D> keeps
extents and indices on the stack when the dimensionality is known while
compiling; DynamicRaster covers other cases.OffsetWalker — odometer traversal of
an arbitrary subset of an image’s dimensions, yielding memory offsets
one addition at a time with no allocation. This is what lets a sub-array
be gathered without permuting the whole image first.ImageSpace and
Affine — voxel-to-world geometry, with the
point conversions and rounding strategies that go with it, and no
dependency on any file format.parallelFor — divides a range into
chunks and runs them concurrently using libdispatch, OpenMP or a plain
loop, whichever was compiled in. Nothing passed to it may touch the R
API, allocate R objects, or draw from R’s RNG, none of which are
thread-safe.dispatchType,
dispatchNarrowType and
dispatchDims — the R-boundary switches
from a runtime SEXP’s storage mode (or dimensionality) to a
compile-time type tag, meant to be called exactly once per entry point
so that everything below it is fully typed.DenseAccessor,
SparseAccessor and
NarrowAccessor, plus
LocationMask — an operator[]
over a plain pointer, a masked and packed array, or narrow storage,
respectively, sharing one interface so that a kernel written against it
serves all three without modification.Sink,
VectorSink,
TypedSink and
ListSink — preallocated result
accumulation for a per-call loop, falling back to a list only once a
result turns out not to fit a plain vector.A kernel that sums a numeric vector, written once against the shared accessor interface, therefore works whether the underlying storage is a plain R vector or something more compact. For example
// [[Rcpp::depends(imply)]]
#include "imply.h"
#include <Rcpp.h>
using namespace imply;
// One kernel body serves dense, sparse and narrow storage alike;
// only the accessor differs
template <typename Accessor>
double sumOver (const Accessor &values, const Extent n)
{
double total = 0;
for (Extent i = 0; i < n; i++)
total += static_cast<double>(values[i]);
return total;
}
// [[Rcpp::export]]
double denseSum (Rcpp::NumericVector x)
{
DenseAccessor<double> accessor(REAL(x));
return sumOver(accessor, Rf_xlength(x));
}Here the templated sumOver() kernel function can handle
any of the image representations, but only has to deal with indexing
over an array of doubles. The denseSum() function shows an
example of it being applied to a dense image.