Reading an image returns an annot_image with dimensions,
pyramid levels, bands, pixel size and a tile accessor,
at_tile(). The ENVI, TIVITA SpecCube and OME readers open
only the metadata and read the requested windows from the file. The
plain TIFF and raster readers read the complete image when it is opened;
TIFF decoding runs in a separate R process, which isolates native
decoder failures but does not reduce the memory the decoded image needs.
at_image_capabilities() reports the effective strategy,
including images that still hold a pixel snapshot in memory. Allow
additional memory for decoder buffers, returned tiles and the tile
cache.
img <- at_example_image("tissue")
img
#> <annot_image> example_tissue.png
#> backend: "raster" | dtype: "uint8"
#> size: 512 x 512 px | levels: 1 | bands: 3
#> pixel size: 1 x 1 px
dim(img)
#> [1] 512 512 3The dim() of a handle is
c(width, height, bands). Printing it summarises the
backend, data type and extent; the metadata accessors use the fields
already read.
str(at_image_capabilities(img))
#> List of 5
#> $ metadata_only: logi FALSE
#> $ window_read : logi FALSE
#> $ samples : chr "display-converted-0-255"
#> $ io : chr "memory"
#> $ lifecycle : chr "retained pixel snapshot"
at_image_capabilities(at_example_image("cube"))$window_read
#> [1] TRUEA handle also exposes accessors for everything an annotation workflow
needs: at_n_bands() and at_bands() for the
channels, at_pixel_size() for physical scale, and
at_meta() for backend-specific keys such as the source path
and data type. Each simply reads a field of the handle — no I/O is
involved.
Slide and multiplex formats store the same scene at several
resolutions — a pyramid. Level 0 is full resolution; each
higher level is a coarser overview. The multiplex example is a
three-level pyramidal TIFF.
mx <- at_example_image("multiplex")
at_n_levels(mx)
#> [1] 3
at_is_pyramidal(mx)
#> [1] TRUE
levels <- 0:(at_n_levels(mx) - 1L)
data.frame(
level = levels,
width = vapply(levels, function(l) at_dims(mx, l)[1], integer(1)),
height = vapply(levels, function(l) at_dims(mx, l)[2], integer(1))
)
#> level width height
#> 1 0 512 512
#> 2 1 256 256
#> 3 2 128 128Levels matter for two reasons. First, an overview lets you display
and navigate the whole image at reduced resolution; windowed readers can
do this without reading full-resolution pixels. Second, an ROI drawn on
a coarse level records the level it was created at, so its
coordinates can be rescaled to level 0 (see
at_roi_rescale() and at_transform()). Every
geometry in hyperannotatR is anchored to a pyramid level; the coordinate
convention is image pixels with the origin at the top-left and
y increasing downward.
A backend is the reader for a particular family of formats. The registry maps format names to four functions — read metadata, fetch a tile, detect a file, and report whether its optional dependencies are installed — so new formats can be added without touching core code.
at_backend_list()
#> # A tibble: 6 × 6
#> name description extensions available metadata_only window_read
#> <chr> <chr> <chr> <lgl> <lgl> <lgl>
#> 1 cuvis Cubert hyperspectral (… cu3, cu3s FALSE FALSE FALSE
#> 2 envi ENVI header + binary c… hdr, dat,… TRUE TRUE TRUE
#> 3 ometiff OME-TIFF and qptiff (R… ome.tif, … TRUE FALSE FALSE
#> 4 raster PNG, JPEG, and single-… png, jpg,… TRUE FALSE FALSE
#> 5 tiff Pyramidal / multi-dire… tif, tiff TRUE FALSE FALSE
#> 6 tivita Diaspective Vision Tiv… dat, hdr TRUE TRUE TRUEThe available column is evaluated lazily: a backend for
a format whose support package is absent still appears in the list but
reads nothing until you install the package. This keeps the package
usable with a minimal set of dependencies, and means a missing optional
package never breaks loading — only reading that particular format. When
you call at_read_image() without naming a backend, the
registry tries the available backends in priority order and picks the
first that recognises the file; more specific readers are tried before
general ones, so an OME-TIFF is not claimed by the plain raster reader.
You can always bypass detection by passing backend =
explicitly.
| Backend | Open / tile strategy | Qualified sample contract and limits |
|---|---|---|
envi |
Metadata / bounded binary runs | BSQ/BIL/BIP, both byte orders, uint8/int16/int32/uint16/uint32/float32/float64; offsets and trailing metadata |
tivita |
Metadata / bounded binary runs | ENVI exports, and big-endian float32 SpecCubes matched to a declared
at_tivita_profile() or sidecar, including synthetic
nonstandard dimensions |
ometiff |
Metadata / native subset windows | RBioFormats + xml2; uint8/uint16/int16/int32/uint32/float32/float64 scalar fixtures plus uint16 CYX/anisotropic pyramids; Z/time reject, explicit multiple-series selection |
tiff |
Eager / memory subset | Qualified raw TIFF sample types below; no window claim, ambiguous directories reject |
raster |
Eager / memory subset | Display conversion to 0–255; original high-bit-depth intensities are not preserved |
cuvis |
Eager / memory subset | CUVIS SDK through cuvis.r 0.1.0; exercised on local vendor data only, not in CI; no bounded-read claim |
| Third-party | Declared by registration / reader | Omitted capabilities remain unknown |
Plain TIFF remains the explicit eager fallback. Its pyramid directories are not silently replaced by a first-directory GDAL view. The qualified OME path uses BioFormats subsets with normalization disabled, preserving image orientation and actual per-level dimensions. Availability is not qualification of every vendor/qptiff layout or deployment. No vendor whole-slide fixture is qualified.
ENVI reads validate positive integer dimensions, interleave, byte
order, sample type, header offset and payload length before allocation.
Header offsets skip leading bytes; trailing metadata is permitted.
Signed int32 image samples, including -2147483648, remain
exact doubles. NPY masks accept 2-D scalar integer arrays in C or
Fortran order, with version 1, 2 or 3 headers. Zero-sized masks,
structured/object dtypes, malformed headers, truncated payloads and
samples outside [-2147483647, 2147483647] are rejected. R
reserves -2147483648 for integer NA, so that
value cannot be an integer mask code. Signed and unsigned 64-bit inputs
are accepted only when every complete value fits this mask range. A NPY
reader consumes the declared array and permits subsequent
bytes/arrays.
Binary file sizes/offsets must fit exact double integer arithmetic
(at most 2^53-1 bytes), and each declared dimension and each returned
window’s element count must fit R’s integer indexing limit. ENVI headers
have a fixed 1 MiB limit; NPY headers have a separate 1 MiB default
configurable with hyperannotatR.max_npy_header_bytes. Array
budgets and their limits are described under Memory
discipline below.
The tiff backend preserves raw grayscale uint8/uint16,
signed int16 and float32 samples, and RGB uint16 samples. Sample type
and photometric metadata determine decoding; integer display
normalisation is reversed without scaling float samples. Unsupported
encodings, including integer32/float64 TIFF in the qualified minimal
tiff installation, fail explicitly. Independent equal-size
TIFF directories are ambiguous and are rejected instead of discarding
planes. A consistent decreasing pyramid retains its actual dimensions at
every level.
OME-TIFF and qptiff detection requires RBioFormats and
xml2. The OME reader normalises [x,y,channel]
to [y,x,band], preserves actual resolution dimensions, and
reads channel names, emission wavelengths and physical pixel sizes from
OME-XML. Missing/incomplete calibration is labelled missing
in image metadata; its pixel grid remains in px. Missing
wavelengths remain NULL. Multiple series require
at_read_image(path, series = ...); non-singleton Z/time
axes are rejected and must be selected/exported as a single plane
beforehand.
Automatic TIFF/OME detection errors when the required raw reader is
absent. Explicit backend = "raster" selects the separate
display-conversion semantics, which can lose sample depth and format
metadata. TIFF mask imports likewise require the raw TIFF reader and a
single finite integer plane.
ENVI source descriptors record the canonical paths, byte sizes and
modification times of both the header and payload, regardless of which
path was opened. TIVITA SpecCubes also record the selected
.tivita.json profile when a sidecar was used. Source checks
verify both the recorded files and current discovery: a new
higher-priority header, payload or profile, or a retargeted companion
symlink, cannot silently select different decoding inputs. A newly
introduced sidecar invalidates a cube previously opened with the default
profile. Explicit at_tivita_profile() arguments continue to
ignore incidental sidecars.
at_inspect_source() reports changed, missing or
unresolved inputs without reading pixels. File-backed tile requests
check the source before consulting the cache and again before caching a
newly read result. These size/mtime checks detect ordinary replacements,
not edits that preserve both values. Concurrent file writers are
unsupported.
Saved TIFF/OME images record a stable semantic contract
(axes = "yxb", samples = "raw-scalar-v1") in
image metadata and durable source descriptors. This describes pixel
meaning, independently of package release or eager/windowed execution.
Mask sidecars retain it; masks with missing affected contracts or
conflicting contracts need an explicit alignment assertion rather than
being certified as source-aligned. Custom backends without this marker
retain their existing descriptor comparison semantics.
On reopen, dimensions, levels, band count, dtype, channel names, wavelengths, units, pixel calibration and the reader contract must agree. TIVITA also checks the effective profile digest and value unit. A stale or ambiguous lightweight project still loads its annotations and IDs, but pixel access reports the mismatch. Historical ENVI descriptors without both companion files, and TIVITA sidecar descriptors without the recorded profile dependency, remain unverified. Today’s files are never used to backfill historical provenance automatically.
After checking that the cube, profile and reader options are the intended ones, inspect and explicitly revalidate the source:
Relinking refreshes reader metadata and retains its geometry/wavelength compatibility guard; an old transposed OME geometry or changed wavelength declaration is not guessed or automatically registered. Caller-modified calibration also requires explicit review if it differs from the reopened reader. Full legacy snapshots whose in-memory pixel handles remain available retain those original pixels, including when saved again. Releasing such a snapshot may make its pixels unavailable if the source cannot be revalidated.
Whatever the backend, pixels come out through one function,
at_tile(), and always as a three-dimensional
[y, x, band] numeric array — even for a single band. You
choose the pyramid level, an optional pixel window, and an optional
subset of bands.
The returned array contains only that 64 x 64 window of every band. Eager backends have already read the underlying image; the window-capable backends read only the requested runs or subsets. Band selections are finite whole indices, keep their order and may repeat. To request a thumbnail, read the coarsest level:
overview <- at_tile(mx, level = 2)
dim(overview)
#> [1] 128 128 4
dim(at_tile(mx, level = 2, bands = 1))
#> [1] 128 128 1Spectral cubes are handled by the very same accessor; their band metadata simply carries wavelengths, which you can read back with the image accessors.
cu <- at_example_image("cube")
at_is_spectral(cu)
#> [1] TRUE
head(at_bands(cu), 3)
#> # A tibble: 3 × 7
#> index name wavelength unit fwhm order wavelength_status
#> <int> <chr> <dbl> <chr> <dbl> <int> <chr>
#> 1 1 Band 1 450 nm NA 1 ok
#> 2 2 Band 2 462. nm NA 2 ok
#> 3 3 Band 3 473. nm NA 3 ok
range(at_wavelengths(cu))
#> [1] 450 900at_band_view() also requires finite numeric whole band
indices: 1.9, "1" and factors are rejected
before integer conversion. Its wavelength_window_mean
operation requires finite numeric bounds with
wavelength_min <= wavelength_max. Band views are display
products; they do not replace the stored samples used for
extraction.
Raw-to-reflectance conversion requires explicitly supplied finite
numeric white and dark reference vectors. Each
reference is a scalar or has one value per band; per-band values follow
the third dimension of a [y,x,band] array, the columns of a
pixel-by-band matrix, or the elements of a spectrum. Factors, strings,
complex values, empty vectors and nonfinite references fail with
CALIBRATION_INVALID, rather than being coerced to different
numbers. A zero white-minus-dark denominator yields NA.
Adding support for a new format means supplying the four functions
and calling at_backend_register(). The read_fn
returns an annot_image — a classed list of metadata plus a
handle for the accessor — and the tile_fn
turns a request into a [y, x, band] array. Here is a
complete, self-contained backend for a toy “constant image” format that
stores only a size and a fill value, and synthesises pixels on
demand.
const_read <- function(path, ...) {
spec <- readRDS(path) # list(width, height, bands, value)
structure(
list(
source = path,
backend = "const",
dims = c(spec$width, spec$height),
n_levels = 1L,
level_dims = list(c(spec$width, spec$height)),
n_bands = spec$bands,
band_names = paste0("Band ", seq_len(spec$bands)),
pixel_size = c(1, 1),
pixel_unit = "px",
dtype = "double",
handle = list(value = spec$value), # no pixels, just the fill value
meta = list()
),
class = "annot_image"
)
}
const_tile <- function(img, level, xrange, yrange, bands) {
nx <- xrange[2] - xrange[1] + 1L
ny <- yrange[2] - yrange[1] + 1L
nb <- if (is.null(bands)) img$n_bands else length(bands)
array(img$handle$value, dim = c(ny, nx, nb))
}
at_backend_register(
name = "const",
read_fn = const_read,
tile_fn = const_tile,
detect_fn = function(path) grepl("\\.const$", path, ignore.case = TRUE),
available_fn = function() TRUE,
description = "Constant-value demo backend",
extensions = "const"
)The new backend now behaves like any other — it appears in the
registry, is auto-detected from the file, and serves tiles through
at_tile().
path <- tempfile(fileext = ".const")
saveRDS(list(width = 64L, height = 48L, bands = 2L, value = 0.5), path)
cimg <- at_read_image(path)
cimg
#> <annot_image> file136616262ab9.const
#> backend: "const" | dtype: "double"
#> size: 64 x 48 px | levels: 1 | bands: 2
#> pixel size: 1 x 1 px
dim(at_tile(cimg, xrange = c(1, 8), yrange = c(1, 8)))
#> [1] 8 8 2The limits below check particular arrays. They do not bound total process memory, temporary copies, native decoder buffers or filesystem cache use.
| Option | Default | What is checked |
|---|---|---|
hyperannotatR.max_tile_bytes |
2 GiB | Every at_tile() request, estimated as eight bytes per
returned sample, before reopening or reading pixels |
hyperannotatR.max_read_bytes |
1 GiB | Decoded double windows for ENVI, SpecCube and OME; for eager NPY imports, the larger of encoded payload bytes and eight bytes per element |
hyperannotatR.cache_size |
512 MiB | Retained R tile objects in the shared cache |
Set a positive finite hyperannotatR.max_read_bytes value
when a trusted array needs a larger limit. A valid windowed source is
not rejected solely because its complete cube exceeds that budget. Plain
TIFF and raster image opening is eager and has no predecode check
against these tile/read budgets: the complete image or pyramid must fit
in memory before a tile is requested. The isolated TIFF decoder protects
the calling process from decoder failures; it does not make TIFF opening
a windowed or memory-budgeted operation.
Binary readers restrict spatial reads to the requested window, but encoded input buffers can include unrequested bands. ENVI BSQ uses contiguous runs per selected band. BIL uses band runs or full-band row chunks; BIP reads every band for the requested rows and columns. SpecCube reads every band for the requested rows of each selected column. Conversion buffers and reordered output copies need additional memory beyond the selected-band array budget. OME delegates subset decoding to BioFormats; compressed blocks may exceed the requested window, and the R array budget does not control its native buffers.
Full-image at_tile() still requests a full image.
Extraction reads one ROI bounding-box tile, without within-ROI
streaming. at_mask() checks a full-grid estimate of 32
bytes per pixel against hyperannotatR.max_read_bytes before
rasterisation. This covers an array-work allowance, not native/geometry
memory; at_mask_stack() can retain several layer masks and
a combined array. Preview, queue and export memory require their own
limits; windowing alone does not make every annotation workflow
bounded.
at_close_image(img) returns a copy with its handle
released and a fresh runtime identity; assign it back. The first
subsequent tile reopens a verified durable descriptor. Repeated
identical requests on that closed image can then use its cache without
reopening again, after validating source freshness. Closing does not
purge the shared cache, whose byte limit still governs retained entries;
the fresh identity prevents reuse of the previous image’s tiles and
counters. Built-in window handles contain only paths/layout metadata,
open each binary connection per read and close it on exit, including
errors. No native pointer is serialized. Registered backends can supply
optional capabilities and
close_fn=function(img) arguments; existing positional
registration calls remain valid. Per-image
meta$capabilities may refine registry declarations. A
retained in-memory array always reports a memory snapshot through
at_image_capabilities().
at_read_stats() counts backend tile reads, payload bytes
and decoded-array bytes under the caller’s image identity, including
after a closed image reopens. Cache hits do not count as backend reads;
header/metadata I/O is not included in the payload counter. This example
makes two identical requests but records one backend tile read:
cu <- at_close_image(at_example_image("cube"))
invisible(at_read_stats(cu, reset = TRUE))
invisible(at_tile(cu, xrange = c(1, 2), yrange = c(1, 2), bands = 1))
invisible(at_tile(cu, xrange = c(1, 2), yrange = c(1, 2), bands = 1))
at_read_stats(cu)[c("tiles", "file_bytes", "decoded_bytes")]
#> $tiles
#> [1] 1
#>
#> $file_bytes
#> [1] 8
#>
#> $decoded_bytes
#> [1] 32For qualification, internal .read_telemetry_reset() /
.read_telemetry() counters can bracket open and tile
operations. binary_bytes counts bytes actually returned by
instrumented binary/header reads and binary_calls counts
those calls. Native metadata/window calls and returned decoded-double
bytes are separate; native encoded file traffic is unknown
(NA), never estimated from the decoded request. These
counters are not OS cache misses, disk throughput or RSS. Measure
process memory separately from before open, including native
startup.
Next, see the The annotation model vignette to learn how ROIs, layers and projects build on these image handles.
at_extract(project, stat = "all", nonfinite = "omit")
computes each ROI and band separately using finite samples only.
n_px (and statistic n) counts selected
support; n_valid counts finite samples and
n_invalid counts NA, NaN and infinities. The default
nonfinite = "propagate" returns NA summary statistics for
any affected ROI/band. "error" rejects selected nonfinite
samples. Empty support emits no rows, a singleton has NA sample SD, and
an all-invalid selection under omit has NA statistics including sum.
Overlapping ROIs count shared pixels separately; ROI rows must not be
pooled as independent pixels or images. The output analysis
attribute records source, level, bands, policy and limitations.
Wavelength values and their unit come from
at_bands(). They are not silently converted or relabelled:
missing ENVI units remain unknown. Spectrum output orders by ROI, unit,
coordinate and band. at_plot_spectrum() labels the declared
spectral coordinate unit and rejects mixed units until explicitly
converted. Display contrast does not change extracted tiles. The raster
fallback already converts source samples to a 0–255 display convention,
so its extraction is not an assertion of original high-bit-depth
intensity preservation.
at_roi_area(roi, units = "physical", image = image) uses
the image’s level-zero pixel calibration and actual pyramid dimensions;
physical area is invariant to reporting level. The result is expressed
in the square of image$pixel_unit. Unknown or pixel-only
calibration rejects. Level-zero legacy ROI pixel_size
attributes remain supported in squared calibration units; nonzero stored
ROI levels require explicit pixel_size_level and
pixel_unit instead of an inferred calibration. Geometric
area differs from the number of raster-selected pixels.
at_mask_agreement() validates annotated masks’ grids,
source descriptors, encodings and codebooks.
alignment = "assert" records an explicit registration
assertion; code_alignment = "by-label" separately aligns
categorical or bitfield codes by semantic labels. Instance alignment
uses stable ROI IDs via "by-id", never repeated class
names. Display labels cannot authorize remapping. Binary
and categorical metrics compare equality; bitfield metrics compare
memberships, with exact-set accuracy and no categorical kappa. Declared
absent classes have NA Dice/IoU and do not contribute to macro means. A
background-only pair has accuracy 1 and undefined (NA) kappa. Plain
matrices remain explicit-code legacy categorical inputs and reject
fractional, nonfinite or unrepresentable values.