Why an attribute, not columns?
Every aniframe carries a metadata list as an R attribute alongside the data columns. The metadata records the things that are true of the recording as a whole rather than of any single observation: the source software, the sampling rate, what units the spatial coordinates are in, where the coordinate origin sits, and so on.
Keeping this information attached to the object — rather than living in a separate file or being passed around as extra arguments — is what lets the rest of the animovement ecosystem stay loosely coupled. A reader (in aniread) populates the metadata at load time, and any downstream tool can read it back without a hand-off.
This article covers the metadata attribute and the functions that read and update it. For the data-column structure see The aniframe data structure; for the connections field specifically see Connections.
The metadata attribute
You can see the full metadata by printing it directly:
data <- example_aniframe()
get_metadata(data)
#> ── aniframe metadata ───────────────────────────────────────────────────────────
#> source (character) : <NA>
#> source_version (character) : <NA>
#> filename (character) : <NA>
#> sampling_rate (numeric) : <NA>
#> start_datetime (POSIXct) : <NA>
#> variables_what (character) : "individual, keypoint"
#> variables_when (character) : "session, trial, time"
#> variables_where (character) : "x, y"
#> unit_space (factor) : "px"
#> [levels: px, none, nm, um, mm, cm, m, km]
#> unit_angle (factor) : "rad"
#> [levels: rad, deg]
#> unit_time (factor) : "frame"
#> [levels: unknown, frame, ns, us, ms, s, m, h]
#> reference_frame (factor) : "allocentric"
#> [levels: allocentric, egocentric]
#> coordinate_system (factor) : "cartesian_2d"
#> [levels: unknown, cartesian_1d, cartesian_2d, cartesian_3d, polar, cylindrical, spherical]
#> origin (factor) : "bottom_left"
#> [levels: bottom_left, top_left]
#> y_height (numeric) : 2.631256
#> connections (list) :The fields and their defaults are defined in one place, default_metadata() — that’s the canonical source of truth for what an aniframe’s metadata looks like.
str(default_metadata(), max.level = 1)
#> List of 16
#> $ source : chr NA
#> $ source_version : chr NA
#> $ filename : chr NA
#> $ sampling_rate : num NA
#> $ start_datetime : POSIXct[1:1], format: NA
#> $ variables_what : chr [1:2] "individual" "keypoint"
#> $ variables_when : chr "time"
#> $ variables_where : chr [1:2] "x" "y"
#> $ unit_space : Factor w/ 8 levels "px","none","nm",..: 1
#> $ unit_angle : Factor w/ 2 levels "rad","deg": 1
#> $ unit_time : Factor w/ 8 levels "unknown","frame",..: 2
#> $ reference_frame : Factor w/ 2 levels "allocentric",..: 1
#> $ coordinate_system: Factor w/ 7 levels "unknown","cartesian_1d",..: 3
#> $ origin : Factor w/ 2 levels "bottom_left",..: 1
#> $ y_height : num NA
#> $ connections : list()
#> - attr(*, "class")= chr "aniframe_metadata"The fields fall into a few groups:
| Group | Fields |
|---|---|
| Provenance |
source, source_version, filename, start_datetime
|
| Sampling | sampling_rate |
| Units |
unit_space, unit_time, unit_angle
|
| Frame of reference |
reference_frame, coordinate_system, origin, y_height
|
| Slot vocabulary |
variables_what, variables_when, variables_where
|
| Relationships | connections |
filename accepts a character vector — readers like aniread::read_trackball() populate it with all source paths.
Reading and writing metadata
get_metadata() and set_metadata() are the workhorses.
get_metadata(data, "sampling_rate")
#> [1] NA
data <- set_metadata(data, sampling_rate = 30, source = "deeplabcut")
get_metadata(data, "sampling_rate")
#> [1] 30
get_metadata(data, "source")
#> [1] "deeplabcut"set_metadata() validates the input — factor fields are checked against their permitted levels, and unknown fields are rejected.
For fields whose update has side effects on the data columns (or on related fields), prefer the dedicated setters listed below.
| Setter | Touches |
|---|---|
set_unit_space() |
converts x/y/z between length units |
set_unit_time() |
converts time between time units |
set_unit_angle() |
converts phi/theta (auto) and any extra cols you supply |
set_sampling_rate() |
flips unit_time from frames to seconds and rescales time
|
set_origin() |
flips the y-axis around y_height when changing convention |
set_y_height() |
sets the recorded frame height used by set_origin()
|
Coordinate origin
For 2D image-derived data there’s an annoying convention split: most image / video tooling uses the top-left corner as (0, 0) (y increases downward), while plotting and most maths uses the bottom-left corner (y increases upward). aniframe records which one your data uses in the origin field, with permitted values c("bottom_left", "top_left").
set_origin() does the actual flip when you change convention. It needs the frame height to compute y_new = y_height - y_old, so y_height must be set first. Readers populate it automatically; for manually-constructed aniframe objects, as_aniframe() falls back to max(y), and set_y_height() lets you override that with the true value.
img <- aniframe(
individual = 1L, time = 1:4,
x = c(0, 10, 20, 30),
y = c(50, 100, 150, 200)
) |>
set_y_height(1080)
img$y
#> [1] 50 100 150 200
img <- set_origin(img, "top_left")
img$y # reflected: 1080 - original y
#> [1] 1030 980 930 880Units
Spatial, temporal, and angular units each have their own setter. Conversions between standard units are automatic; conversions from unknown / frame / px units require an explicit calibration factor (or, for time, a sampling_rate).
data <- example_aniframe(n_dims = 2) |>
set_metadata(unit_space = "mm")
data_cm <- set_unit_space(data, to_unit = "cm")
get_metadata(data_cm, "unit_space")
#> [1] cm
#> Levels: px none nm um mm cm m km
data <- example_aniframe() # default unit_time = "frame"
data_s <- set_sampling_rate(data, sampling_rate = 30)
get_metadata(data_s, "unit_time") # now "s"
#> [1] s
#> Levels: unknown frame ns us ms s m h
range(data_s$time) # frames divided by fps
#> [1] 0.03333333 1.66666667Spatial angular columns (phi, theta) are converted automatically by set_unit_angle() whenever they’re present. Pass cols only for non-spatial angular columns (e.g. heading direction).
pol <- aniframe(
individual = 1L, time = 1:3,
rho = c(1, 1, 1), phi = c(0, pi / 2, pi)
)
pol$phi
#> [1] 0.000000 1.570796 3.141593
pol_deg <- set_unit_angle(pol, to_unit = "deg")
pol_deg$phi
#> [1] 0 90 180