An R package for analysing and visualising headings and trajectories on the unit disk — directional (angular) data of any kind. It covers the full pipeline: angle/trajectory input → coordinate transformation → circular statistics → ggplot2 visualisation. Bring a table of headings directly, or reconstruct them from movement trajectories, with import from 20+ tracking tools.
Key Features
- Flexible loader framework — read data from 20+ tracking tools via registered dialects; extend with custom formats
- Pose-estimation support — multi-bodypart centroid and body-axis heading from DeepLabCut, EthoVision multi-zone, ANY-maze nose/tail
- Heading rule registry — derive per-trial headings by any of 15+ built-in rules or register your own
-
Circular statistics — mean direction, resultant length, concentration, and grouped summaries via the
circularpackage -
ggplot2 visualisation —
radiate()renders unit-circle plots of headings and trajectories with concentric guides, mean-direction arrows, tick marks, and faceting; composable withadd_*layer helpers
Supported Tracking Tools
| Dialect | Tool |
|---|---|
dtrack |
dtrack |
deeplabcut / deeplabcut_multiheader
|
DeepLabCut (single point or multi-bodypart centroid) |
idtrackerai_wide |
idtracker.ai |
ethovision |
EthoVision XT (including multi-zone) |
trackmate |
TrackMate (Fiji) |
toxtrac |
ToxTrac |
boris_xy |
BORIS |
trex |
TRex (positional CSV; plain X/Y or #wcentroid/#centroid/#pcentroid variants) |
anymaze |
ANY-maze (including nose/tail zones) |
tracktor |
Tracktor |
motchallenge |
MOTChallenge / SORT / DeepSORT |
geojson_linestring |
GeoJSON LineString |
gpx |
GPX tracks |
nmea_gprmc |
NMEA GPRMC |
wide_prefix_xy |
Generic x_<id> / y_<id> wide format |
Register additional formats with register_loader_dialect() or the declarative register_loader_format().
Heading Rules
derive_headings() dispatches to any of the built-in rules or a registered custom rule:
| Rule | Method |
|---|---|
crossing |
Angle at ring-crossing event (two radii) |
distal |
Angle at frame of maximum radial distance |
exit |
Velocity direction at ring crossing |
net |
Start-to-end vector |
velocity_mean |
Circular mean of per-frame velocity angles |
vm_fit |
von Mises MLE over per-frame angles |
ransac_straight |
RANSAC-fitted straight-segment direction |
origin_mean |
Distance-weighted mean angle from origin |
bodypart_axis |
Axis between two tracked keypoints (pose data) |
ellipse_axis |
Pre-computed orientation angle column (e.g. a theta column) |
| … |
straight, window_net, goal_bias, pca_axis, maxspeed_window, entry, ring_tangent
|
Register custom rules with register_heading_rule().
Circular statistics
Beyond summaries, radiatR covers the common circular-statistics workflow — all returning tidy data frames, and most accepting axial = TRUE for bidirectional (mod-180°) data such as orientation or polarization axes.
-
Uniformity / modality tests —
test_uniformity()runs the Rayleigh (default), Kuiper, Rao spacing, Watson, and Hermans–Rasson (Monte-Carlo, powerful against multimodal alternatives) tests. -
Parametric fits —
vonmises_fit()andwrappedcauchy_fit()(each with anaxialmode), overlaid on plots withadd_vonmises_density()/add_wrappedcauchy_density(). -
Model selection —
circ_model_select()ranks uniform vs unimodal vs axial von Mises by AICc with Akaike weights, so the data indicate the modality. -
Summaries & association —
circ_summary()/circ_summarise(),circ_dispersion(), andcirc_cor()(circular–linear and circular–circular).
hd <- derive_headings(cpunctatus, rule = "distal")
# Is the sample non-uniform, and how is it best described?
test_uniformity(hd, test = "hermans_rasson") # omnibus; catches multimodality
circ_model_select(hd) # uniform / unimodal / axial by AICcThe graphical app surfaces these too: a selectable omnibus test and a model-selection readout alongside the summary table.
Circular regression
Model a heading on linear covariates (Fisher-Lee circular-linear regression):
# simulate a known predictor -> mean-heading effect, then recover it
cond <- data.frame(condition = "demo", n_trials = 150, ref_mean = 0,
concentration_base = 12, mean_slope = 0.6,
predictor_mean = 0, predictor_sd = 1)
s <- simulate_tracks(conditions = cond, n_points = 8, seed = 1)
hd <- s[!duplicated(s$trial_id), c("predictor", "final_heading")]
names(hd)[2] <- "heading"
fit <- circ_regression(hd, heading ~ predictor)
summary(fit) # tidy coefficient table (recovers the positive slope)
predict(fit, data.frame(predictor = c(-1, 0, 1)))
# draw the fitted sweep on the circular panel
radiate(headings_frame(hd, heading, units = "radians")) +
add_circ_mean(fitted_directions(fit, at = seq(-2, 2, length.out = 7)),
colour_col = "predictor")Typical Workflow
library(radiatR)
# 1. Load tracking data (example: DeepLabCut with head + thorax bodyparts)
ts <- read_tracks(
"my_tracks.csv",
dialect = "deeplabcut",
dialect_args = list(bodypart = c("head", "thorax"))
)
# 2. Derive headings
hd <- derive_headings(ts, rule = "crossing",
circ0 = 0.3, circ1 = 0.6,
coords = "absolute",
angle_convention = "unit_circle")
# 3. Circular summary per condition
compute_circ_mean(hd, group_col = "condition")
# 4. Visualise
radiate(ts,
group_col = "trial_id",
colour_col = "condition",
facets = "condition",
show_arrow = TRUE) +
add_heading_points(hd, colour_col = "condition")Coordinates. radiatR does not calibrate your data. By default,
(x, y)are used exactly as supplied, so headings, mean direction, resultant length, and other circular statistics are only as arena-accurate as your input coordinates. Correct lens distortion and any scaling to real-world units in your tracking pipeline (e.g. your tracker’s own calibration, or OpenCVundistort) before importing.normalize_xy = TRUEis available when only trajectory shape matters (each trajectory is independently centred on its own bounding box and scaled to the unit circle) – it is not a substitute for calibration and does not preserve bearing relative to a fixed arena origin. When a per-trial reference direction is available, the landmark-relative frame (rel_x/rel_y,coords = "relative"inderive_headings()) is the accurate option.
Label the circumference in domain units
The circumference can be labelled in domain units instead of degrees — handy for time-of-year or compass data. angle_labels selects a built-in scale ("cardinal", "hours", "months", "seconds") and aligns the tick count to it:
# Label the circle in months instead of degrees
radiate(ts, angle_labels = "months")Colour tracks by position
track_colour = "sequence" shades each trajectory along its own length, so the direction of travel is visible at a glance — a per-track gradient from start (dark) to finish (bright), with a continuous “start → finish” colourbar:
# colour each track from start (dark) to finish (bright)
radiate(cpunctatus, show_tracks = TRUE, track_colour = "sequence")Representing time
Attach a capture frame rate to a Tracks object and the time aspect of frame-indexed tracks can be reported in real seconds and shown on the plot. track_duration() gives the elapsed seconds per trajectory, and track_colour = "time" colours each path by elapsed time (POSIXct time works without a frame rate):
# attach a capture rate, then represent the time aspect
ts <- set_frame_rate(cpunctatus, fps = 30)
track_duration(ts) # seconds per track
radiate(ts, show_tracks = TRUE, track_colour = "time") # colour by elapsed timeA frame rate also lets track_speed() report trajectory speed in real units (radii per second):
# speed in radii per second once a frame rate is set
ts <- set_frame_rate(cpunctatus, fps = 30)
track_speed(ts) # mean speed per track
track_speed(ts, stat = "max")
# colour each path by its instantaneous speed (needs a frame rate)
ts <- set_frame_rate(cpunctatus, fps = 30)
radiate(ts, show_tracks = TRUE, track_colour = "speed")If you know a physical scale, calibrate distances so lengths and speeds report in real units. The scale is physical units per coordinate unit (e.g. mm per radius); unset, everything stays in coordinate units (radii).
# calibrate distance (optional): 50 mm per coordinate unit (radius)
ts <- set_distance_scale(set_frame_rate(cpunctatus, 30), 50, unit = "mm")
track_length(ts) # path length per track, in mm
track_speed(ts) # mean speed per track, in mm/s
# or from two measured landmarks:
# ts <- calibrate_distance(ts, coord_distance = 0.8, real_distance = 40, unit = "mm")
# velocity components and turning rate (need a frame rate; vx/vy use the distance scale)
ts <- set_frame_rate(cpunctatus, fps = 30)
head(velocity_vector(ts)) # vx, vy per observation
head(angular_velocity(ts, units = "degrees")) # turning rate, deg/s (CCW +)
# per-track summaries (need a frame rate; velocity uses the distance scale)
ts <- set_frame_rate(cpunctatus, fps = 30)
track_velocity(ts) # net velocity vector (vx, vy) per track
track_turning(ts, units = "degrees") # typical turning rate (deg/s) per track
# non-circular kinematics: speed (or turning rate) along each track over time
ts <- set_frame_rate(cpunctatus, fps = 30)
plot_profile(ts, metric = "speed")
plot_profile(ts, metric = "turning", units = "degrees")Circular boxplot
A Tukey-like boxplot for circular data (Buttarazzi, Pandolfo & Porzio, 2018): the box spans the central 50% around the circular median, whiskers reach a concentration-adjusted fence, and far-out values are marked individually. Position-based axial data is supported via axial = TRUE (drawn at both poles).
hd <- derive_headings(cpunctatus, rule = "crossing", circ0 = 0.2, circ1 = 0.4)
radiate(cpunctatus) + add_circular_boxplot(hd)
# the summary on its own (median, hinges, fences, far-out, fence multiplier)
circ_boxplot_stats(hd)$constantSimulate Data
simulate_tracks() generates synthetic circular trajectories for testing pipelines and teaching:
# Three default conditions differing in concentration and tortuosity
ts <- simulate_tracks(seed = 42, output = "trajset")
radiate(ts, group_col = "trial_id", facets = "condition", show_arrow = TRUE)
# Custom conditions table
conds <- data.frame(
condition = c("low", "high"),
n_trials = 20L,
concentration_base = c(1.5, 8),
tortuosity_base = c(0.12, 0.03)
)
ts2 <- simulate_tracks(conditions = conds, seed = 1, output = "trajset")Each condition can also carry a known directional ground truth. Set a modality (uniform / unimodal / axial / multimodal, with n_modes) to control the distribution of per-track headings, and a track shape (directed or oscillatory) to control within-track geometry — oscillatory tracks move back and forth along an axis, the kind of data the position-based axial heading methods (pca_axis, ransac_straight) are built for:
# Oscillatory axial tracks: each moves back-and-forth along a ~0.6 rad axis
osc <- data.frame(condition = "axial_osc", n_trials = 40L, ref_mean = 0.6,
concentration_base = 50, track_shape = "oscillatory",
n_reversals = 4L)
ts <- simulate_tracks(conditions = osc, output = "trajset", seed = 1)
# the position-based axial methods recover the ~0.6 rad axis (read as an
# axial mean) at default settings:
ax <- derive_headings(ts, rule = "pca_axis")
circ_summarise(ax, "heading", axial = TRUE, units = "radians", stats = "mean_dir")
# a directional rule cannot: the back-and-forth directions cancel
net <- derive_headings(ts, rule = "net")
circ_summarise(net, "heading", units = "radians", stats = "resultant_R")Installation
# From r-universe (pre-built binaries)
install.packages("radiatR",
repos = c("https://johnkirwan.r-universe.dev",
"https://cloud.r-project.org"))
# From GitHub
remotes::install_github("JohnKirwan/radiatR")
# From local clone
devtools::install_local(".")The graphical app
A browser-based interface covers the common workflow — upload a tracking file, choose a heading method, and view circular plots and summary statistics — with no R coding required.
Try it live: https://019f950c-e3cd-3ba0-a8cb-2a8a76a14dd5.share.connect.posit.cloud/
There are two ways to reach it:
1. Run locally (works after installing the package):
radiatR::launch_app()2. Hosted on Posit Connect Cloud (recommended for collaborators) — https://019f950c-e3cd-3ba0-a8cb-2a8a76a14dd5.share.connect.posit.cloud/. A real Shiny server runs R server-side, so the page loads like any normal web app. Connect Cloud is git-backed: it is linked to this GitHub repo and rebuilds inst/app on every push, resolving R package dependencies from the committed inst/app/manifest.json — there are no deploy secrets. Regenerate that manifest with the connect-manifest workflow (Actions → Run workflow) whenever the app’s dependencies or pinned R version change, then merge the PR it opens. To host your own copy, connect your fork in the Connect Cloud dashboard (content directory inst/app, primary file app.R). For a one-off manual deploy to any Shiny server instead:
rsconnect::deployApp(system.file("app", package = "radiatR"))