--- title: "Masks" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Masks} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} resource_files: - figures/logo.svg - figures/logo-dark.svg --- ```{r setup, include=FALSE} # The example images are a PNG (read with magick) and TIFFs (read and written # with tiff); without them the code is shown but not run. has_deps <- requireNamespace("magick", quietly = TRUE) && requireNamespace("tiff", quietly = TRUE) knitr::opts_chunk$set(collapse = TRUE, comment = "#>", fig.width = 6, fig.height = 4, eval = has_deps) library(hyperannotatR) ``` 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()`. ## Grid size and memory budget `at_mask()` checks dimensions and the full grid before allocating raster arrays, including empty masks. Dimensions must be positive and finite; fractional extents round up, so `dims = c(3.1, 2.1)` produces four columns and three rows. A grid beyond supported integer indexing fails with `MASK_TOO_LARGE` before rasterisation. The existing `hyperannotatR.max_read_bytes` option also limits mask work. The estimate is `32 * width * height` bytes for output, coverage arrays and working copies. Its default of `1024^3` bytes (1 GiB) permits 33,554,432 pixels per grid; larger requests fail with `READ_BUDGET_EXCEEDED`. The error includes `requested_bytes` and `limit_bytes` in its `details`. For a trusted larger mask, set a suitable byte limit before calling `at_mask()`, or choose a coarser pyramid level when available. The estimate is a preflight check, not a guarantee about total process memory (RSS). It excludes geometry and native-library overhead. `at_mask_stack()` retains per-layer masks and builds a combined stack, so this per-grid check does not bound its aggregate memory use. Account for the number of layers and other live arrays when choosing a limit. ## 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 worked} 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)) ``` 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: ```{r touches} 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) off on ``` 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): ```{r overlap} 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) matrix(as.integer(at_mask(lyr, "labelled", dims = c(8, 8), overlap = "first")), 8, 8) ``` `"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()`. ```{r bitor} bits <- at_mask(lyr, "multiclass", dims = c(8, 8), values = c(a = 1L, b = 2L), overlap = "bitor") matrix(as.integer(bits), 8, 8) at_mask_legend(bits)[, c("value", "label", "n_px")] ``` ## Masks from a project On a real project the mask spans the image grid. The bundled example carries one layer of three labelled regions: ```{r project} proj <- at_example_project() mc <- at_mask(proj, type = "multiclass") mc at_mask_legend(mc) table(as.integer(mc)) ``` `at_mask_stats()` summarises each value — pixel counts, area, fraction of the frame, bounding box and centroid — ready for a report table: ```{r stats} at_mask_stats(mc) ``` ## 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 `.legend.json` describing it: ```{r write} dir <- tempfile("masks-"); dir.create(dir) path <- file.path(dir, "regions.tif") at_write_mask(mc, path, overwrite = TRUE) list.files(dir) cat(readLines(paste0(path, ".legend.json")), sep = "\n") ``` 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: ```python 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: ```{r read} back <- at_read_mask(path) vapply(back$rois, function(r) r$label, character(1)) ``` 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: ```{r plot} at_plot_mask(mc) ``` Next, see the **Interoperability** vignette for GeoJSON, QuPath, and CSV exchange with other tools.