Skip to contents

Why an attribute, not columns?

Every anipoint 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, which way its axes point, 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 connections, which live in the structure category, see Structures; for axis directions and orientation, see Orientation.

The metadata attribute

You can see the metadata by printing it directly. Fields that are not set are named together on the last line:

data <- example_anipoint()
get_metadata(data)
#> ── animovement metadata ────────────────────────────────────────────────────────
#> 
#> ── time 
#> unit_time: frame
#> sampling_interval: 1 frame
#> 
#> ── space 
#> coordinate_system: cartesian_2d
#> reference_frame: allocentric
#> handedness: unknown
#> unit_space: px
#> unit_angle: rad
#> 
#> ── variables 
#> what   keys: individual, keypoint
#> when   index: time | keys: session, trial
#> where  position: x = x, y = y
#> event  state: - | point: -
#> 
#> Not set: source, source_version, source_format, source_sampling_rate, filename,
#>   sampling_rate, start_datetime, axis_directions, axis_extents, euler_sequence,
#>   euler_intrinsic, structure

all = TRUE prints every field, the values each factor field allows, and spec_version:

print(get_metadata(data), all = TRUE)
#> ── animovement metadata ────────────────────────────────────────────────────────
#> 
#> ── recording 
#> source: -
#> source_version: -
#> source_format: -
#> source_sampling_rate: -
#> filename: -
#> 
#> ── time 
#> unit_time: frame
#>   levels: unknown, frame, ns, us, ms, s, m, h
#> sampling_rate: -
#> sampling_interval: 1 frame
#> start_datetime: -
#> 
#> ── space 
#> coordinate_system: cartesian_2d
#>   levels: unknown, cartesian_1d, cartesian_2d, cartesian_3d, polar, cylindrical,
#>     spherical
#> reference_frame: allocentric
#>   levels: allocentric, egocentric, none
#> handedness: unknown
#>   levels: right, left, unknown
#> axis_directions: -
#> axis_extents: -
#> unit_space: px
#>   levels: px, none, nm, um, mm, cm, m, km
#> unit_angle: rad
#>   levels: rad, deg, none
#> euler_sequence: -
#> euler_intrinsic: -
#> 
#> ── variables 
#> what   keys: individual, keypoint
#> when   index: time | keys: session, trial
#> where  position: x = x, y = y
#> event  state: - | point: -
#> 
#> ── structure 
#> -
#> 
#> spec_version: aniframe 3.0.0, anievent 1.0.0

The fields and their defaults are defined in one place, list_default_metadata() — that’s the canonical source of truth for what an anipoint’s metadata looks like.

str(list_default_metadata(), max.level = 2)
#> List of 6
#>  $ spec_version:List of 2
#>   ..$ aniframe: chr "3.0.0"
#>   ..$ anievent: chr "1.0.0"
#>  $ recording   :List of 5
#>   ..$ source              : chr NA
#>   ..$ source_version      : chr NA
#>   ..$ source_format       : chr NA
#>   ..$ source_sampling_rate: num NA
#>   ..$ filename            : chr NA
#>  $ time        :List of 4
#>   ..$ unit_time        : Factor w/ 8 levels "unknown","frame",..: 2
#>   ..$ sampling_rate    : num NA
#>   ..$ sampling_interval: num NA
#>   ..$ start_datetime   : POSIXct[1:1], format: NA
#>  $ space       :List of 9
#>   ..$ coordinate_system: Factor w/ 7 levels "unknown","cartesian_1d",..: 3
#>   ..$ reference_frame  : Factor w/ 3 levels "allocentric",..: 1
#>   ..$ handedness       : Factor w/ 3 levels "right","left",..: 3
#>   ..$ axis_directions  : Named chr(0) 
#>   .. ..- attr(*, "names")= chr(0) 
#>   ..$ axis_extents     : Named num(0) 
#>   .. ..- attr(*, "names")= chr(0) 
#>   ..$ unit_space       : Factor w/ 8 levels "px","none","nm",..: 1
#>   ..$ unit_angle       : Factor w/ 3 levels "rad","deg","none": 1
#>   ..$ euler_sequence   : chr NA
#>   ..$ euler_intrinsic  : logi NA
#>  $ variables   :List of 4
#>   ..$ what :List of 1
#>   ..$ when :List of 2
#>   ..$ where:List of 1
#>   ..$ event:List of 2
#>  $ structure   : list()
#>  - attr(*, "class")= chr "aniframe_metadata"

The fields are grouped into categories, each answering one kind of question:

Category Fields
recording provenance: source, source_version, source_format, source_sampling_rate, filename
time unit_time, sampling_rate, sampling_interval, start_datetime
space coordinate_system, reference_frame, handedness, axis_directions, axis_extents, unit_space, unit_angle, euler_sequence, euler_intrinsic
variables which columns play which role: what, when, where, event, each a list of named slots
structure relationships between levels of a variable — today’s connections

spec_version sits above the categories: it is metadata about the metadata, versioning the contract they describe. space is the one category a class can lack — an anievent has no spatial component, so its metadata simply has no space rather than a set of “none” values.

filename accepts a character vector — readers like aniread::read_trackball() populate it with all source paths.

Two sampling rates

A frame keeps two rates apart. source_sampling_rate is the rate the device recorded at: provenance, which no processing changes. sampling_rate is the rate of the data as it is now, the one filters and conversions compute with, and the one resampling changes. The first rate declared fills both, so a reader that sets sampling_rate records the camera’s rate without saying so:

rates <- set_metadata(example_anipoint(), sampling_rate = 200)
get_metadata(rates, "source_sampling_rate")
#> [1] 200

rates <- set_metadata(rates, sampling_rate = 50) # e.g. after resampling
get_metadata(rates, "time")$sampling_rate
#> [1] 50
get_metadata(rates)
#> ── animovement metadata ────────────────────────────────────────────────────────
#> 
#> ── time 
#> unit_time: frame
#> sampling_rate: 50 Hz (recorded at 200 Hz)
#> sampling_interval: 1 frame
#> 
#> ── space 
#> coordinate_system: cartesian_2d
#> reference_frame: allocentric
#> handedness: unknown
#> unit_space: px
#> unit_angle: rad
#> 
#> ── variables 
#> what   keys: individual, keypoint
#> when   index: time | keys: session, trial
#> where  position: x = x, y = y
#> event  state: - | point: -
#> 
#> Not set: source, source_version, source_format, filename, start_datetime,
#>   axis_directions, axis_extents, euler_sequence, euler_intrinsic, structure

The compact print shows the recorded rate beside the current one only when they differ. A device with no fixed rate, such as an event-driven sensor, is declared with source_sampling_rate = NaN, which the first declaration leaves alone; NA means the rate has not been declared yet.

Reading and writing metadata

get_metadata() and set_metadata() are the workhorses. The categories group the storage, but access stays flat: a field is found and written by its own name wherever it lives, and a category name returns the whole category.

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"

names(get_metadata(data, "space"))
#> [1] "coordinate_system" "reference_frame"   "handedness"       
#> [4] "axis_directions"   "axis_extents"      "unit_space"       
#> [7] "unit_angle"        "euler_sequence"    "euler_intrinsic"

set_metadata() validates the input — factor fields are checked against their permitted levels, and unknown fields are rejected.

set_* functions only declare: they record a fact about the data and never change a value. Operations that change values have their own verbs and update the metadata to match:

Function Does
convert_unit_space() rescales the length axes to another unit
convert_unit_time() rescales the index to another unit, using sampling_rate to and from frames
convert_unit_angle() converts phi/theta (and any cols) between rad and deg
reflect_axis() turns an axis over, reflecting its column

convert_unit_time() follows the frame’s own index rather than a column called time — see the index.

Declaring the slot vocabulary

The variables category is a special case: its roles name columns rather than describing values, so a name that matches nothing is a promise the frame can’t keep. All but the event role go further — they are not a description of the frame, they are its structure. as_anipoint() uses them to coerce column types, order columns and rows, group the frame, and derive coordinate_system. Writing them without redoing that work would leave the frame and its own metadata disagreeing — the print header would update while the grouping still reflected the old declaration.

The category holds one list per role, each with named slots: what$keys (identity), when$index and when$keys (the position within a context, and the context itself — an anievent has when$interval instead of an index), where$position (the axis-role mapping), and event$state / event$point. The frame groups by c(what$keys, when$keys) and nothing else.

set_metadata() refuses the whole category, and points you at the setters that do the whole job:

data |> set_metadata(variables_what = "id")
#> Error in `ensure_no_declaration_fields()`:
#> ! `set_metadata()` cannot write variables_what directly.
#> ℹ This entry declares which columns carry identity, time, position and events,
#>   or how levels connect. Writing it here would leave the metadata naming
#>   columns the frame may not have, and the frame ordered and grouped as it was
#>   before.
#> ℹ Use `set_variables()` instead, which validate the columns exist and
#>   restructure the frame to match.
#> ℹ A complete metadata object can still be restored wholesale, as in
#>   `set_metadata(data, metadata = get_metadata(x))`.

The category has one family of verbs: get_variables(), set_variables(), add_variables() and remove_variables(), each taking a role and, for the setters, a list of slots or a character vector for the role’s main slot. set_index() and get_keys() are shorthands. The column has to exist before it can be declared, so the order is always create-then-declare:

tagged <- data |>
  dplyr::mutate(id = "trial_1") |>
  add_variables(what = "id")

get_variables(tagged, "what")
#> [1] "individual" "keypoint"   "id"
dplyr::group_vars(tagged)
#> [1] "individual" "keypoint"   "id"         "session"    "trial"

add_variables() appends to the declaration, so you don’t have to restate what is already there — forgetting to would quietly demote an existing identity variable and regroup the frame without it.

Declaring a spatial column refreshes the fields derived from it:

data |>
  dplyr::mutate(z = 0) |>
  add_variables(where = "z") |>
  get_metadata("coordinate_system")
#> [1] cartesian_3d
#> 7 Levels: unknown cartesian_1d cartesian_2d cartesian_3d polar ... spherical

event is the fourth role, and the one that doesn’t change the frame’s shape: it declares which columns carry per-frame event labels, split into interval-valued state columns and instantaneous point columns. to_anievent() reads it to know what to encode.

data |>
  dplyr::mutate(behaviour = factor("rest")) |>
  set_variables(event = list(state = "behaviour")) |>
  get_variables("event", "state")
#> [1] "behaviour"

Which way the axes point

axis_directions records which way each axis points, read from where the recording was made — right/left, up/down, back/forward — and axis_extents how far it runs. set_axis_directions() declares the directions; reflect_axis() turns an axis over and changes the data to match. The sense of rotation and the handedness are derived from them by get_angle_direction() and get_handedness().

data |>
  set_axis_directions(c(x = "right", y = "down")) |>
  get_angle_direction()
#> [1] "clockwise"

Orientation sets out the model, including why the depth axis matters even for 2D data.

Units

Declare a unit with set_metadata(); convert values with convert_unit_*(). Factors between standard units are derived; converting from px or unknown needs a calibration_factor, and to or from frame a declared sampling_rate.

data <- example_anipoint(n_dims = 2) |>
  set_metadata(unit_space = "mm")

data_cm <- convert_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_anipoint() |> # default unit_time = "frame"
  set_metadata(sampling_rate = 30)
data_s <- convert_unit_time(data, "s")
range(data_s$time) # frames divided by fps
#> [1] 0.03333333 1.66666667

Frames count from 0, so the first frame is at 0 s. Converting back to frames uses the rate the frame declares, which is how a wrong rate is put right: back to frames, declare the right rate, and forward again. A wrong camera rate is the recorded rate too, so it is declared as both.

data_s |>
  convert_unit_time("frame") |>
  set_metadata(sampling_rate = 25, source_sampling_rate = 25) |>
  convert_unit_time("s") |>
  get_sampling_interval()
#> [1] 0.04

Frames computed from a rate are nominal, so converting to frames rounds to the nearest whole frame and refuses sampling that is irregular by more than 1%, or rounding that would put two times on one frame. Frame numbers a file recorded are data: if the frame has a column named frame that is not its index, converting to frames makes that column the index again.

Spatial angular columns (phi, theta) are converted automatically by convert_unit_angle() whenever they’re present. Pass cols only for non-spatial angular columns (e.g. heading direction).

pol <- anipoint(
  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 <- convert_unit_angle(pol, to_unit = "deg")
pol_deg$phi
#> [1]   0  90 180