Skip to contents

hyperannotatR logo

A mask is the artefact that turns hand-drawn regions into something a segmentation or classification pipeline can consume: an integer raster on the same pixel grid as the image, where each pixel carries the identity of the region it belongs to. In hyperannotatR the mask is the core deliverable, and at_mask() is the function that produces it from an annot_roi, an annot_layer, or a whole annot_project.

Three mask types

at_mask() writes one of three rasters, chosen with type:

  • "binary" — a logical matrix: TRUE wherever any region covers a pixel, FALSE elsewhere. Use it for foreground/background problems (“is this pixel annotated at all?”).
  • "labelled" — one distinct positive integer per ROI, assigned in draw order. Use it when individual objects must stay separable, e.g. instance segmentation where two adjacent tumour nests are different things.
  • "multiclass" — one integer per label class, so every ROI sharing a label collapses to the same value. Use it for semantic segmentation, where you care about the class (“tumour”, “stroma”) and not the particular ROI.

Every mask is an annot_mask: a matrix with an attached legend tibble (value, label, layer, roi_id, n_px, colour) plus level, dims, mask_type and mask_metadata attributes; the last records how to read the values (below). Read the legend back with at_mask_legend().

Encoding metadata and alignment

attr(mask, "mask_metadata") is a versioned semantic description. Schema 1 records encoding, background, overlap, a grid (dims in width/height order, level, origin, and sampling stride), the full codebook, and source provenance. Rasterisation uses origin (0,0) and stride (1,1). A nearest-neighbour preview records its sampled dimensions and stride plus preview$parent_grid; its counts describe only sampled pixels.

Public type names remain unchanged. Ordinary multiclass masks use categorical encoding: category 3 belongs only to class 3. With overlap = "bitor", codes must be distinct positive powers of two and background must be zero. A sample 3 then belongs to both class 1 and class 2. Legends, statistics, previews, polygonisation, and display use this same membership rule. Composite display labels join their class names with + and use the mean class colour. A supplied global values map survives even if some classes have no pixels; those classes retain zero counts. Empty legends recover with typed columns.

Foreground codes cannot equal background. Binary rasterisation rejects a requested background of 1, including for empty masks. Other accepted binary backgrounds become effective FALSE/0, matching the logical stored samples. NPY imports remain integer arrays, including arrays declared binary.

Derivation uses each input’s own background and anatomy membership, preserving the full state codebook. Declared grids and durable source descriptors must agree. Queue entry IDs are retained as provenance but need not match for two annotations of the same source; reopened-image cache generations never establish source equality. Display names alone are insufficient. Reader options are compared exactly, including atomic types, names, dimensions, nesting, attributes and formal S4 identity. Sidecars store options with options_encoding = "typed-json-v2": a versioned, data-only representation that also records each value’s formal-object flag. Supported atomic/list S4 values recover without class constructors or an installed class definition. Numeric options retain 17-digit precision. Malformed or future option encodings fail. Older untyped JSON and typed schema 1 cannot prove that all option structure survived (schema 1 omitted formal-object flags): these sources are marked options_status = "legacy-json" and derivation requires an explicit alignment assertion, including after re-export. Likewise, options_inferred = TRUE records guessed legacy reader options and cannot establish verified alignment. Any supplied inference flag must be a single, non-missing logical value; omitting it leaves explicitly recorded options unchanged. For standalone masks whose registration you know, pass alignment = "assert" to at_mask_derive(). This records an explicit registration assertion without resampling. Different matrix dimensions always fail. The default alignment = "legacy" only permits unknown-source fallback when both masks have legacy metadata and records legacy-assumed. Self-combination records self-grid, without claiming an external source registration.

The pixel-coverage contract

Rasterisation is only reproducible if the pixel/geometry relationship is exact, so hyperannotatR pins it down. Pixel (i, j) covers the half-open square [j-1, j) x [i-1, i), with (0, 0) at the top-left corner of the top-left pixel; its centre is therefore (j - 0.5, i - 0.5). With the default touches = FALSE, a pixel is in the mask when its centre lies inside the polygon. Boundary ties use the symbolic sample (x + epsilon, y + epsilon^2) as epsilon tends to zero from above; there is no finite coordinate shift. At each centre y, edges are active on [minY, maxY) and sorted crossing pairs fill [left, right). For rectangles, lower edges count as inside and upper edges as outside. For the triangle (0,0), (5,0), (5,5), the diagonal y = x counts as inside: its five rows contain 5, 4, 3, 2, and 1 pixels, respectively. Hole rings remove pixels by parity regardless of ring orientation, and MultiPolygon components combine by union. Thin polygons without selected centres produce an empty mask. Single and batched masks and both engine choices share this centre-membership rule.

Points use their half-open containing cell: point (5,5) selects matrix row 6, column 6, because its cell is [5,6) x [5,6). Multipoints select the union of these cells. Extraction uses the same support and reads the corresponding bounded image tile. These boundary rules correct the earlier finite-shift behavior, which selected row 5, column 5 for an integer-coordinate point and could produce inconsistent diagonal polygon coverage.

All-touched masks and line rasterisation retain the selected engine’s behavior; engine equality is guaranteed for centre-based polygons and points.

The cleanest way to trust this is to compute a small case by eye. Take a rectangle from (2, 2) to (5, 5) on a 10x10 grid:

r <- at_roi_rect(2, 2, 5, 5, label = "a")
m <- at_mask(r, "binary", dims = c(10, 10))
print(matrix(as.integer(m), 10, 10))
#>       [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8] [,9] [,10]
#>  [1,]    0    0    0    0    0    0    0    0    0     0
#>  [2,]    0    0    0    0    0    0    0    0    0     0
#>  [3,]    0    0    1    1    1    0    0    0    0     0
#>  [4,]    0    0    1    1    1    0    0    0    0     0
#>  [5,]    0    0    1    1    1    0    0    0    0     0
#>  [6,]    0    0    0    0    0    0    0    0    0     0
#>  [7,]    0    0    0    0    0    0    0    0    0     0
#>  [8,]    0    0    0    0    0    0    0    0    0     0
#>  [9,]    0    0    0    0    0    0    0    0    0     0
#> [10,]    0    0    0    0    0    0    0    0    0     0

Rows and columns 3, 4 and 5 are set, and nothing else. The rectangle spans x in [2, 5); the pixel-centre x-coordinates that fall in that interval are 2.5, 3.5, 4.5, i.e. columns 3, 4, 5. The centre at x = 5.5 (column 6) is outside, and the lower edge at x = 2 is inside because centre 2.5 > 2. The same reasoning on y gives rows 3:5. A rectangle three pixels wide and three pixels high covers exactly nine pixels, with no fencepost surprises.

touches: centres versus contact

touches = TRUE switches to a “does the polygon touch the pixel at all” rule (GDAL ALL_TOUCHED=TRUE), which grows the region by the fringe pixels a polygon merely clips. It matters when edges cut through pixel interiors rather than running along the grid. A rectangle with fractional bounds shows the contrast:

r2  <- at_roi_rect(2.5, 2.5, 4.5, 4.5, label = "a")
off <- matrix(as.integer(at_mask(r2, "binary", dims = c(8, 8), touches = FALSE)), 8, 8)
on  <- matrix(as.integer(at_mask(r2, "binary", dims = c(8, 8), touches = TRUE)),  8, 8)
#> Registered S3 method overwritten by 'stars':
#>   method                  from
#>   st_interpolate_aw.stars sf
off
#>      [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8]
#> [1,]    0    0    0    0    0    0    0    0
#> [2,]    0    0    0    0    0    0    0    0
#> [3,]    0    0    1    1    0    0    0    0
#> [4,]    0    0    1    1    0    0    0    0
#> [5,]    0    0    0    0    0    0    0    0
#> [6,]    0    0    0    0    0    0    0    0
#> [7,]    0    0    0    0    0    0    0    0
#> [8,]    0    0    0    0    0    0    0    0
on
#>      [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8]
#> [1,]    0    0    0    0    0    0    0    0
#> [2,]    0    0    0    0    0    0    0    0
#> [3,]    0    0    1    1    1    0    0    0
#> [4,]    0    0    1    1    1    0    0    0
#> [5,]    0    0    1    1    1    0    0    0
#> [6,]    0    0    0    0    0    0    0    0
#> [7,]    0    0    0    0    0    0    0    0
#> [8,]    0    0    0    0    0    0    0    0

With touches = FALSE only the pixels whose centres sit inside the rectangle are kept; with touches = TRUE the partially clipped border pixels join too. Prefer the default for area-faithful masks, and touches = TRUE only when you must not drop thin structures.

Overlap resolution

When two regions cover the same pixel, overlap decides who wins. The ROIs are rasterised in draw order (ascending z, then insertion):

lyr <- at_layer("regions")
lyr <- at_layer_add(lyr, at_roi_rect(1, 1, 5, 5, label = "a"))
lyr <- at_layer_add(lyr, at_roi_rect(3, 3, 7, 7, label = "b"))
matrix(as.integer(at_mask(lyr, "labelled", dims = c(8, 8), overlap = "last")),  8, 8)
#>      [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8]
#> [1,]    0    0    0    0    0    0    0    0
#> [2,]    0    1    1    1    1    0    0    0
#> [3,]    0    1    1    1    1    0    0    0
#> [4,]    0    1    1    2    2    2    2    0
#> [5,]    0    1    1    2    2    2    2    0
#> [6,]    0    0    0    2    2    2    2    0
#> [7,]    0    0    0    2    2    2    2    0
#> [8,]    0    0    0    0    0    0    0    0
matrix(as.integer(at_mask(lyr, "labelled", dims = c(8, 8), overlap = "first")), 8, 8)
#>      [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8]
#> [1,]    0    0    0    0    0    0    0    0
#> [2,]    0    1    1    1    1    0    0    0
#> [3,]    0    1    1    1    1    0    0    0
#> [4,]    0    1    1    1    1    2    2    0
#> [5,]    0    1    1    1    1    2    2    0
#> [6,]    0    0    0    2    2    2    2    0
#> [7,]    0    0    0    2    2    2    2    0
#> [8,]    0    0    0    0    0    0    0    0

"last" (the default) lets the later-drawn region overwrite; "first" keeps the earlier one; "max" and "min" resolve by pixel value; and "error" aborts the moment any two regions collide, which is the safest choice when overlaps should never happen.

"bitor" keeps every membership instead of choosing a winner. It is meant for multiclass masks whose class codes are distinct powers of two: an overlapping pixel holds the bitwise OR of the codes, so a pixel covered by a (1) and b (2) holds 3 and both classes stay recoverable with bitwAnd().

bits <- at_mask(lyr, "multiclass", dims = c(8, 8), values = c(a = 1L, b = 2L),
                overlap = "bitor")
matrix(as.integer(bits), 8, 8)
#>      [,1] [,2] [,3] [,4] [,5] [,6] [,7] [,8]
#> [1,]    0    0    0    0    0    0    0    0
#> [2,]    0    1    1    1    1    0    0    0
#> [3,]    0    1    1    1    1    0    0    0
#> [4,]    0    1    1    3    3    2    2    0
#> [5,]    0    1    1    3    3    2    2    0
#> [6,]    0    0    0    2    2    2    2    0
#> [7,]    0    0    0    2    2    2    2    0
#> [8,]    0    0    0    0    0    0    0    0
at_mask_legend(bits)[, c("value", "label", "n_px")]
#> # A tibble: 2 × 3
#>   value label  n_px
#>   <int> <chr> <int>
#> 1     1 a        16
#> 2     2 b        16

Masks from a project

On a real project the mask spans the image grid. The bundled example carries one layer of three labelled regions:

proj <- at_example_project()
mc <- at_mask(proj, type = "multiclass")
mc
#> <annot_mask> multiclass  |  512 x 512 px  |  level 0
#> values: 3
#> # A tibble: 3 × 6
#>   value label    layer   roi_id  n_px colour 
#>   <int> <chr>    <chr>   <chr>  <int> <chr>  
#> 1     1 tumour   regions NA     19600 #999999
#> 2     2 necrosis regions NA     72704 #E69F00
#> 3     3 stroma   regions NA     34200 #56B4E9
at_mask_legend(mc)
#> # A tibble: 3 × 6
#>   value label    layer   roi_id  n_px colour 
#>   <int> <chr>    <chr>   <chr>  <int> <chr>  
#> 1     1 tumour   regions NA     19600 #999999
#> 2     2 necrosis regions NA     72704 #E69F00
#> 3     3 stroma   regions NA     34200 #56B4E9
table(as.integer(mc))
#> 
#>      0      1      2      3 
#> 135640  19600  72704  34200

at_mask_stats() summarises each value — pixel counts, area, fraction of the frame, bounding box and centroid — ready for a report table:

at_mask_stats(mc)
#> # A tibble: 3 × 12
#>   value label     n_px area_px area_physical frac_total bbox_xmin bbox_ymin
#>   <int> <chr>    <int>   <dbl>         <dbl>      <dbl>     <dbl>     <dbl>
#> 1     1 tumour   19600   19600            NA     0.0748       120       150
#> 2     2 necrosis 72704   72704            NA     0.277          0       370
#> 3     3 stroma   34200   34200            NA     0.130        300        30
#> # ℹ 4 more variables: bbox_xmax <dbl>, bbox_ymax <dbl>, centroid_x <dbl>,
#> #   centroid_y <dbl>

Writing masks and the legend sidecar

at_write_mask() writes an integer TIFF (16-bit by default), and — because a bare integer raster is meaningless without a key — a companion <path>.legend.json describing it:

dir  <- tempfile("masks-"); dir.create(dir)
path <- file.path(dir, "regions.tif")
at_write_mask(mc, path, overwrite = TRUE)
list.files(dir)
#> [1] "regions.tif"             "regions.tif.legend.json"
cat(readLines(paste0(path, ".legend.json")), sep = "\n")
#> {
#>   "mask_metadata": {
#>     "schema_version": 1,
#>     "status": "declared",
#>     "encoding": "categorical",
#>     "background": 0,
#>     "overlap": "last",
#>     "grid": {
#>       "dims": [512, 512],
#>       "level": 0,
#>       "origin": [0, 0],
#>       "stride": [1, 1]
#>     },
#>     "source": {
#>       "entry_id": null,
#>       "descriptor": {
#>         "schema_version": 1,
#>         "signature": {
#>           "size": 526756,
#>           "mtime": 1792424030
#>         },
#>         "files": null,
#>         "path": "/home/runner/work/_temp/Library/hyperannotatR/extdata/example_tissue.png",
#>         "backend": "raster",
#>         "options": {
#>           "schema_version": 2,
#>           "node": {
#>             "type": "list",
#>             "data": [],
#>             "s4": false,
#>             "attributes": null
#>           }
#>         },
#>         "options_identity": "580a0000000200040601000203000000001300000000",
#>         "reader_contract": null,
#>         "options_encoding": "typed-json-v2"
#>       }
#>     },
#>     "codebook": [
#>       {
#>         "value": 1,
#>         "label": "tumour"
#>       },
#>       {
#>         "value": 2,
#>         "label": "necrosis"
#>       },
#>       {
#>         "value": 3,
#>         "label": "stroma"
#>       }
#>     ]
#>   },
#>   "storage": null,
#>   "annotatR_version": "0.3.0",
#>   "created": "2026-10-19T15:35:30+0000",
#>   "mask_type": "multiclass",
#>   "level": 0,
#>   "dims": [512, 512],
#>   "source_project": "example",
#>   "legend": [
#>     {
#>       "value": 1,
#>       "label": "tumour",
#>       "layer": "regions",
#>       "roi_id": null,
#>       "n_px": 19600,
#>       "colour": "#999999"
#>     },
#>     {
#>       "value": 2,
#>       "label": "necrosis",
#>       "layer": "regions",
#>       "roi_id": null,
#>       "n_px": 72704,
#>       "colour": "#E69F00"
#>     },
#>     {
#>       "value": 3,
#>       "label": "stroma",
#>       "layer": "regions",
#>       "roi_id": null,
#>       "n_px": 34200,
#>       "colour": "#56B4E9"
#>     }
#>   ]
#> }

The sidecar is self-describing. Its fields, printed above, include the annotatR_version, the created timestamp, the mask_type, the pyramid level, the raster dims, the source_project, and the full legend array (one object per value, mirroring the legend tibble). The sidecar also contains mask_metadata schema 1. NPY sidecars separately record storage$axes (yx or xy) and storage$shape; these describe the array on disk, while the metadata grid describes logical image samples. For a transposed NPY export, read with at_read_npy(..., transpose = TRUE). A mismatched explicit orientation fails even when a square shape would conceal the error. at_read_mask() recovers the declared NPY orientation automatically. Any consumer can map pixel values back to labels without touching R.

TIFF export accepts bits = 8L or bits = 16L (the default) and preserves unsigned integer class codes in [0, 255] or [0, 65535]. PNG export remains 8-bit only: values above 255 and all negative values reject before writing. bits controls TIFF only. Both formats reject fractional/nonfinite samples and out-of-range values before creating or overwriting the destination. TIFF writing needs tiff or a magick build with exact grayscale support; PNG needs the latter. An unavailable lossless writer produces a capability error. Single-choice arguments require a scalar: omitted defaults choose the first documented value, while explicitly supplied vectors are invalid.

Downstream consumption

A Python pipeline reads the TIFF and its legend directly — no R runtime, no bespoke format:

import json
import tifffile

mask = tifffile.imread("regions.tif")            # integer array, image grid
with open("regions.tif.legend.json") as f:
    legend = json.load(f)

value_to_label = {row["value"]: row["label"] for row in legend["legend"]}
tumour = mask == next(v for v, l in value_to_label.items() if l == "tumour")

Reading masks back

Masks are not a dead end. at_read_mask() polygonises an existing mask — hyperannotatR’s own output, or a Cellpose/StarDist/QuPath raster — back into an editable annot_layer, recovering labels from the sidecar when it is present:

back <- at_read_mask(path)
vapply(back$rois, function(r) r$label, character(1))
#> [1] "tumour"   "necrosis" "stroma"

Recovery precedence is explicit arguments, embedded RDS metadata, then sidecar. legend and level override the corresponding fields of an optional metadata list. For example, a known legacy bitfield can be imported with metadata = list(encoding = "bitfield", overlap = "bitor") and a supplied legend of its base bits. Overrides do not resample the array. Legacy masks without sufficient encoding metadata default to exclusive instance/categorical values and background zero, with status = "legacy-default"; composite values missing from a legacy legend fail instead of being guessed as bits. Signed representable integer categories are valid. Corrupt dimensions, noninteger samples, malformed legends, and unknown schema versions fail explicitly. Sampled previews and translated grids cannot be polygonised into editable ROIs; import the original-resolution mask with unit stride and zero origin instead. Their sampled arrays remain available through at_read_npy(), preview, and statistics APIs. TIFF masks must be a single two-dimensional integer plane; fractional samples and multiple planes are rejected before conversion. Raw TIFF import requires tiff; it does not fall back to display grayscale conversion. PNG import preserves unsigned 8/16-bit grayscale class codes, including 65535, using a magick build that passes an exact grayscale capability check. Palette, RGB, alpha-bearing, grayscale tRNS transparency and other-depth PNGs reject before polygonisation. A transparency key is rejected even if none of the stored samples use that key.

connectivity = 8L (default) joins pixels that share a corner or edge into one ROI. connectivity = 4L joins only edge neighbours. Point-touching parts of an eight-connected region are retained together as valid multipart geometry with the same pixel membership. Connectivity must be a single finite whole number; missing, fractional, other-valued or explicit vector arguments are invalid.

Finally, at_plot_mask() renders a mask with its legend for a quick visual check:

Next, see the Interoperability vignette for GeoJSON, QuPath, and CSV exchange with other tools.