---
title: "The annotation model"
output: rmarkdown::html_vignette
vignette: >
%\VignetteIndexEntry{The annotation model}
%\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)
```
hyperannotatR represents an annotation as a small hierarchy of plain R objects, each a
validated layer built on the one below. This vignette describes that model: the
pixel coordinate system every object shares, the four nested classes, how
geometric validity is checked and repaired, and how coordinates move between
pyramid levels. These few conventions are enough to use the rest of the package
with confidence.
## The coordinate system
Every coordinate in hyperannotatR is an image pixel coordinate. The origin sits at the
top-left corner of the image, `x` increases to the right, and `y` increases
*downward* -- the raster convention used by cameras, microscopes, and most image
libraries. This is the mirror image of the GIS convention, in which `y` increases
upward. When exchanging geometry with GIS-oriented tooling, flip at the boundary
with `at_flip_y()`.
A pixel is a unit cell, not a point. Pixel `(i, j)` -- row `i`, column `j`, both
one-based -- covers the half-open square `[j-1, j) x [i-1, i)`, so its centre lies
at `(j - 0.5, i - 0.5)`. Membership follows that centre: a pixel belongs to a
region when its centre falls inside the polygon. Boundary ties are evaluated
symbolically at `(x + epsilon, y + epsilon^2)` as `epsilon` tends to zero from
above. For axis-aligned rectangles this is the half-open `[lower, upper)` rule;
it also defines sloping edges deterministically. Holes use ring parity and
MultiPolygon components combine by union. Points instead select their half-open
containing cells, so point `(5,5)` belongs to matrix row 6, column 6. Masks and
extraction share these rules.
```
x increases --->
0 1 2 3
0 +-----+-----+-----+
| | | | pixel (row 2, col 2)
1 +-----+-----+-----+ covers [1, 2) x [1, 2)
y | | o | | centre = (1.5, 1.5) <- "o"
| | | 1.5 | |
v 2 +-----+-----+-----+
| | | |
3 +-----+-----+-----+
```
A rectangle spanning columns and rows 2--3 makes the rule concrete. Testing the
centres of the four pixels along the diagonal, only those whose centre falls
inside are matched:
```{r contains}
sq <- at_roi_rect(1, 1, 3, 3, label = "cell")
at_roi_contains(sq, x = c(0.5, 1.5, 2.5, 3.5), y = c(0.5, 1.5, 2.5, 3.5))
```
## The object hierarchy
Annotations are built from four nested classes: `annot_roi` -> `annot_layer` ->
`annot_project` -> `annot_session`.
An **`annot_roi`** is a single region of interest: an `sf` geometry (a point,
polygon, ellipse, and so on) stored in pixel coordinates at a declared pyramid
level, together with a label, a unique identifier, and provenance.
```{r roi}
rect <- at_roi_rect(120, 150, 260, 290, label = "tumour")
rect
at_roi_area(rect)
```
An **`annot_layer`** groups ROIs that share a controlled label vocabulary and a
drawing style. Layers are the unit of visibility, locking, and z-order. Adding an
ROI never mutates the input; it returns an extended copy.
```{r layer}
regions <- at_layer("regions", labels = c("tumour", "stroma"))
regions <- at_layer_add(regions, rect)
regions <- at_layer_add(regions, at_roi_circle(400, 120, r = 40, label = "stroma"))
regions
```
An **`annot_project`** binds one image to a named set of layers and records
provenance. It is the object you query, validate, rasterise, and export. `at_rois()`
returns a tidy table in which coordinates, areas, and centroids are expressed at
level 0. The `level` column is therefore always 0; `source_level` records where
each ROI was originally defined. Older query tables used `level` for that
provenance: consumers must switch to `source_level` when they need the stored
level. Empty tables have the same schema. Querying never changes the stored ROI.
```{r project}
img <- at_example_image("tissue")
proj <- at_project(img, regions, name = "demo")
at_layers(proj)
at_rois(proj)[, c("roi_id", "layer", "label", "geom_type", "level", "source_level", "area_px")]
```
Finally, an **`annot_session`** wraps a queue of images with a manifest, a cursor,
and shared label and layer templates, so a project can be materialised, annotated,
and saved for each image in turn.
```{r session}
sess <- at_example_session(3)
at_session_status(sess)[, c("idx", "name", "status")]
```
## Geometry validity and repair
Every constructor finalises its geometry: the CRS is cleared and, if the result is
topologically invalid, `sf::st_make_valid()` is applied with a warning. ROIs you
build in R are therefore valid by construction. Geometry that arrives from
elsewhere -- imported GeoJSON, a QuPath project, a hand-edited file -- carries no
such guarantee, so hyperannotatR offers an explicit audit.
`at_check_geometry()` reports one row per problem across the whole project:
self-intersections, zero-area or degenerate rings, duplicate vertices, reversed
hole winding, `NA` coordinates, and vertices outside the image bounds, each tagged
by severity and by whether it can be repaired automatically. Here we add one ROI
that spills past the image edge and one with a duplicated vertex:
```{r check}
proj <- at_add_roi(proj, "regions", at_roi_rect(430, 430, 620, 620, label = "tumour"))
dup <- at_roi_polygon(
matrix(c(0, 0, 0, 0, 40, 0, 40, 40, 0, 40), ncol = 2, byrow = TRUE),
label = "stroma"
)
proj <- at_add_roi(proj, "regions", dup)
at_check_geometry(proj)
```
`at_fix_geometry()` repairs what it can: here it drops the duplicated vertex and
pulls the rectangle that spills past the edge back inside the image bounds.
Issues it cannot repair, such as zero-area rings or `NA` coordinates, are left in
place and reported. To confine a single ROI to the image without the other
repairs, use `at_clamp()`.
```{r fix}
proj <- at_fix_geometry(proj)
at_check_geometry(proj)
```
## Pyramid levels and coordinate transforms
Whole-slide and multiplex images are pyramids: the same scene stored at several
resolutions. An ROI records the level at which its coordinates were digitised in
its `level` field, so a region drawn on a low-resolution overview is never
silently misread as full-resolution pixels. Keeping the declared level makes every
coordinate unambiguous and every transform reversible.
Two functions move coordinates between levels. `at_roi_rescale()` assumes an exact
power-of-two pyramid and needs no image: going from level 0 to level 2 divides
coordinates by four.
```{r rescale}
r0 <- at_roi_rect(0, 0, 100, 100, label = "a")
r2 <- at_roi_rescale(r0, from_level = 0, to_level = 2)
at_roi_bbox(r2)
```
`at_transform()` instead uses the real per-level dimensions of an image, so it is
exact for pyramids with any downsampling ratio. For the bundled multiplex example,
whose levels measure 512, 256, and 128 pixels, the two approaches agree:
```{r transform}
mimg <- at_example_image("multiplex")
at_dims(mimg, level = 0)
at_dims(mimg, level = 2)
at_roi_bbox(at_transform(r0, from_level = 0, to_level = 2, img = mimg))
```
Coordinates can round-trip through pyramid levels within floating-point
precision (relative tolerance `1e-12`).
Computations that change levels require the actual image dimensions. Pass
`image = img` to standalone masks, pixel-area measurements, set operations,
and distance/overlap measurements, or use a project that carries its image.
A bare ROI's dimensions do not establish a pyramid scale. Set operations and
intersection predicates can operate on standalone ROIs at the same stored
level; mixed levels require image metadata. With image metadata, set-operation
results use level 0. Distance and pairwise overlap areas always use level 0.
The companion **Masks** vignette shows how these annotations are rasterised --
applying the pixel-centre rule above -- into binary, labelled, and multi-class
integer masks.