5  A mouse in the elevated plus maze

In this case study, we’ll use movement to analyse a mouse navigating an elevated plus maze (EPM)—a widely used behavioural assay for measuring anxiety in rodents. Our goal is to quantify how much time the mouse spends in different regions of the maze.

We assume you are already familiar with the movement dataset structure and the movement graphical user interface (GUI), both introduced in Chapter 4.

NoteUsing the right environment

If you are following along this chapter on your own computer, make sure to run all code snippets with the animals-in-motion-env environment activated (see prerequisites A.3.3). This also applies to launching the movement GUI (Section 4.2).

5.1 The elevated plus maze

The EPM is a simple maze in the shape of a plus sign, raised on a platform above the ground. It consists of four arms radiating from a central square: two opposite “open arms” (corridors without walls) and two opposite “closed arms” (corridors enclosed by walls). Mice are generally averse to open, exposed spaces, so they tend to prefer the closed arms. The proportion of time spent in the open arms is therefore commonly used as a measure of anxiety-like behaviour: mice that spend more time exploring the open arms are interpreted as exhibiting lower anxiety.

5.2 Dataset

This tutorial uses one of movement’s sample datasets: DLC_single-mouse_EPM.predictions.h5. The dataset consists of DeepLabCut pose‑tracking predictions for a single mouse performing an elevated plus maze task. The recording is approximately 10 minutes long at 30 fps and includes eight tracked keypoints, along with the corresponding video and a representative still frame.

NoteAcknowledgement

The EPM video and DeepLabCut files were kindly shared by Loukia Katsouri from the Sainsbury Wellcome Centre.

5.3 Load and inspect the data

First, let’s import everything we need:

import matplotlib.pyplot as plt
import numpy as np

from movement import sample_data
from movement.filtering import filter_by_confidence, rolling_filter
from movement.plots import plot_occupancy
from movement.roi import compute_region_occupancy, load_rois
Downloading data from 'https://gin.g-node.org/neuroinformatics/movement-test-data/raw/master/metadata.yaml' to file '/home/runner/.movement/data/temp_metadata.yaml'.

2026-08-16 22:59:09.659 | WARNING  | movement.sample_data:_fetch_metadata:133 - Failed to download the newest sample metadata file. Will use the existing local version instead.

In Chapter 4 we loaded pose tracks from a file on disk using movement’s load_dataset function. Here, we will fetch the EPM dataset directly using movement’s sample_data.fetch_dataset function, since it’s one of movement’s curated sample datasets, which are downloaded on demand and cached under ~/.movement/data. We will also pass with_video=True so that the associated video is downloaded as well—we’ll need it later.

ds = sample_data.fetch_dataset(
    "DLC_single-mouse_EPM.predictions.h5",
    with_video=True,
)
ds
<xarray.Dataset> Size: 4MB
Dimensions:     (time: 18485, space: 2, keypoint: 8, individual: 1)
Coordinates: (4)
Data variables:
    position    (time, space, keypoint, individual) float64 2MB 508.4 ... 390.5
    confidence  (time, keypoint, individual) float64 1MB 0.0002829 ... 0.9978
Attributes: (7)

Along with the pose tracks, this sample dataset comes with a video and a still frame extracted from it. Their paths are stored as attributes of the dataset:

print(ds.frame_path)
print(ds.video_path)
/home/runner/.movement/data/frames/single-mouse_EPM_frame-20sec.png
/home/runner/.movement/data/videos/single-mouse_EPM_video.mp4

Take note of these paths—we’ll need them to later draw regions of interest in the GUI.

Let’s also look at the still frame to visualise the maze configuration used in this dataset:

frame = plt.imread(ds.frame_path)

fig, ax = plt.subplots()
ax.imshow(frame)
ax.set_title("Elevated plus maze")
plt.show()
Figure 5.1: A still frame from the video, showing the elevated plus maze from above.

The camera looks down on the maze from above. The two closed arms run vertically, while the two open arms are the bare horizontal platforms extending left and right from the central square.

5.4 Clean the data

Pose estimation output inevitably contains low-confidence or erroneous predictions, especially around occluded body parts. This is also true for our dataset, so some preprocessing is required before we proceed with the analysis.

We saw an example of how to clean the data in Chapter 4. This time we will implement the cleaning steps ourselves.

TipExercise 5.1

Clean the pose tracks:

  1. Discard predictions with a confidence score below 0.95, using filter_by_confidence. Which keypoint loses the most data? Does that make sense given the geometry of the maze?
  2. Smooth the result with a rolling median filter, using rolling_filter and a window of length 5 frames. Set min_periods=2, so that a smoothed value is produced whenever at least 2 of the 5 frames in the window are present (rather than requiring all 5).

Name the resulting cleaned position array position_clean—we’ll use it for the rest of the chapter.

5.5 Visualise space occupancy

Before defining any regions of interest, it’s useful to examine the mouse’s overall occupancy to understand how it explored the maze.

In Chapter 4, we relied on xarray’s built-in plotting methods. The movement.plots module provides additional plotting functions designed specifically for motion-tracking data. Among them is plot_occupancy, which by default produces a 2D histogram of position based on the centroid of all keypoints.

NoteMultiple individuals

If the dataset contains more than one individual, plot_occupancy pools the centroid positions of all individuals by default when computing the histogram. To restrict the plot to specific individuals, supply the individuals argument (and keypoints to control which keypoints are used for the centroid).

We’ll overlay the histogram on the still frame that came with the dataset, so we can see the maze geometry underneath.

Code
frame = plt.imread(ds.frame_path)
height, width = frame.shape[:2]

# Bins of 30x30 pixels, covering the whole frame
bin_size = 30
bins = [
    np.arange(0, width + bin_size, bin_size),
    np.arange(0, height + bin_size, bin_size),
]

fig, ax = plt.subplots()
ax.imshow(frame)

plot_occupancy(
    position_clean,
    ax=ax,
    bins=bins,
    alpha=0.8,
    cmin=10,  # hide bins with fewer than 10 frames
    norm="log",
)

# Match the axes to the frame, keeping the image orientation
ax.set_xlim(0, width)
ax.set_ylim(height, 0)
ax.set_title("Occupancy heatmap")
ax.collections[0].colorbar.set_label("# frames")
Figure 5.2: Occupancy of the mouse’s centroid across the maze, on a logarithmic colour scale.

Aside from individuals, keypoints and ax, all remaining keyword arguments are forwarded to matplotlib’s hist2d, which provides the bins, cmin and norm parameters. A logarithmic colour scale is recommended in this context, as the most frequently visited bins otherwise compress the dynamic range and obscure structure in the less‑visited regions.

The trajectories reflect the plus-shaped layout of the maze, with the centre appearing as one of the most visited regions. To quantify occupancy within particular regions in further detail, we must first define these regions explicitly.

5.6 Define ROIs in napari

A region of interest (ROI) is a shape defined in the same pixel coordinates as the tracking data. By combining ROIs with pose tracks, we can relate the animal’s position to meaningful areas of the camera view, and answer questions such as whether the mouse was inside a given arm of the maze at a given time.

The easiest way to define a region of interest is using the movement GUI (see Section 4.2 if you need a refresher).

TipExercise 5.2

Draw the regions of the maze in the GUI and save them to a file.

The steps below are a condensed version of the define regions of interest section of the movement GUI guide. You can refer to the guide for the more detailed steps.

  1. Launch the GUI from the command line with movement launch.
  2. Load a background layer: drag and drop either the video or the still frame onto the viewer, using the paths we printed above.
  3. Expand the “Define regions of interest” menu on the right-hand side, and click “Add new layer”. This adds a napari shapes layer named “regions”, managed by movement.
  4. With that layer selected, press P to activate the polygon tool and draw around one region of the maze, clicking once per vertex. Each shape you draw appears as a row in the regions table.
  5. Repeat until you have five regions: the two open arms (left and right), the two closed arms (top and bottom), and the central square.
  6. Double click the name cell of each row to rename it. We’ll use open_arm_left, open_arm_right, closed_arm_top, closed_arm_bottom and centre.
  7. Click “Save layer” and save the regions to a .geojson file.

Any napari shape can become a movement region, but the resulting object in movement depends on what you drew: polygons, rectangles and ellipses become PolygonOfInterest objects, while lines and paths become LineOfInterest objects. The latter are useful for boundaries an animal may cross, rather than areas it may occupy.

5.7 Load and visualise ROIs

We can now read the regions we defined in the GUI into Python with load_rois.

For reproducibility, we’ll use the EPM_rois.geojson file provided in the course materials (it’s included in the course-animals-in-motion GitHub repository you cloned in Section A.3.3). You may also use your own geojson file created in Section 5.6; the results should be comparable.

rois = load_rois("data/EPM_rois.geojson")

[roi.name for roi in rois]
['closed_arm_top',
 'closed_arm_bottom',
 'open_arm_right',
 'open_arm_left',
 'centre']

The variable rois is a list of region objects, each carrying its name and sequence of vertices:

print(rois[0])
PolygonOfInterest closed_arm_top (4-gon)
(594.374755859375, 214.4383544921875) -> (590.34619140625, 458.0010986328125) -> (706.9757690429688, 458.6462097167969) -> (707.3343505859375, 214.0797576904297) -> (594.374755859375, 214.4383544921875)

Additionally, every region can draw itself onto a matplotlib axes with its plot method. This is very helpful to double check that our regions line up with the maze.

fig, ax = plt.subplots()
ax.imshow(frame)

for roi, colour in zip(rois, plt.cm.Dark2.colors, strict=False):
    roi.plot(ax, facecolor=colour, alpha=0.5)

ax.legend(loc="upper right", fontsize="small")
Figure 5.3: The five regions of interest drawn over the EPM.

A PolygonOfInterest also has methods to compute convenient geometric variables such as:

  • the distance from a point to a region’s boundary,
  • the nearest point on a boundary, or
  • the angle at which an animal approaches a region.

See the movement.roi API reference and the boundary angles example for what’s possible.

5.8 Compute time spent in each ROI

To determine whether the mouse was inside a region of interest, we first need to define what “being in a region” means. The simplest approach is to reduce the mouse to a single representative point.Other definitions are possible—for example, checking whether any keypoint lies inside a region, or what fraction of keypoints do—but using a single point is straightforward, and we adopt that convention here.

For this representative point, we’ll use the centroid across all keypoints, computed as in Chapter 4.

centroid = position_clean.mean(dim="keypoint")

We can now compute the occupancy in each of the ROIs we defined, by passing the centroid and the regions to compute_region_occupancy.

occupancy = compute_region_occupancy(centroid, rois)
occupancy
<xarray.DataArray 'position' (region: 5, time: 18485, individual: 1)> Size: 92kB
False False False False False False ... False False False False False False
Coordinates: (3)
Attributes: (1)

If you inspect the output closely, you will notice the output array mirrors the input centroid array, except that the space dimension has been replaced by a region dimension whose coordinates are the region names defined in the GUI. You will also notice the output occupancy is a boolean array: True when the centroid lies inside a given region at a given time, and False otherwise.

We can use the occupancy boolean array to directly compute the proportion of time spent in each region, by summing over time and dividing by the number of frames.

time_per_region = 100 * occupancy.mean("time")
time_per_region = time_per_region.squeeze()  # drop the single-individual dim

fig, ax = plt.subplots()
ax.bar(time_per_region.region.values, time_per_region.values)
ax.set_ylabel("% time spent in region")
ax.tick_params(axis="x", rotation=30)
Figure 5.4: Percentage of the session the mouse’s centroid spent in each region.

Here taking the mean over time is the same as summing over time and dividing by the number of frames: occupancy.mean("time") is equivalent to occupancy.sum("time") / occupancy.sizes["time"]. But this equivalence holds here because occupancy is a boolean array, which cannot contain NaN values.

Be careful when applying the same shortcut to floating-point data: xarray’s mean ignores NaN values by default, so they are dropped from both the sum and the count. The denominator is then the number of valid frames rather than the total number of frames.

We can see that the mouse spent a comparable amount of time in the open and closed arms, and a sizeable fraction of the session in the centre.

If you inspect time_per_region, you may notice the percentages don’t add up to 100%.

print(f"Accounted for: {float(time_per_region.sum()):.1f}% of frames")
Accounted for: 98.2% of frames

The denominator is the total number of frames, but not every frame is counted in some region. A frame contributes to no region at all if the centroid is NaN (position missing after cleaning), or if it falls in a part of the arena we didn’t cover with a region.

TipExercise 5.3

As discussed, the centroid is not the only way to represent the overall position of the mouse.

  1. Recompute the occupancy using only the tailbase keypoint in place of the centroid, and plot the resulting percentages alongside those obtained from the centroid.
  2. How do occupancy times change? Can you infer why?

5.9 Counting entries and exits

The time spent in a region is an aggregate metric across the entire session. To understand how this dwell time accumulated over the course of the session, the number of entries into each region offers a useful complementary measure—one that can be extracted from the boolean occupancy array.

If we compute the consecutive differences of the occupancy array along the time dimension, we get an array that identifies, for each frame, whether it corresponds to an entry (1), an exit (-1), or neither (0). We can then count the number of entries and exits per region by summing over time.

transitions = occupancy.astype(int).diff(dim="time")

entries = (transitions == 1).sum("time").squeeze()
exits = (transitions == -1).sum("time").squeeze()

for region in entries.region.values:
    print(
        f"{region:<20} "
        f"entries: {int(entries.sel(region=region)):>4} "
        f"exits: {int(exits.sel(region=region)):>4}"
    )
closed_arm_top       entries:   28 exits:   27
closed_arm_bottom    entries:   16 exits:   16
open_arm_right       entries:   34 exits:   34
open_arm_left        entries:   47 exits:   47
centre               entries:  126 exits:  126

Entries and exits are almost perfectly balanced for each region, which provides a useful consistency check: over the course of a session, every entry into a region must be paired with a subsequent exit, and every exit must correspond to a prior entry. Only the first and last frames can violate this pairing: an initial exit without a matching entry occurs if the mouse begins the session inside a region, and a final entry without a matching exit occurs if the mouse ends the session inside a region. So the two counts should either be identical, or differ by exactly one in these boundary cases. Any larger discrepancy indicates a problem in how we derived the transitions.

The centre is entered far more often than the arms, reflecting the typical pattern in which the mouse probes an arm briefly and then retreats back to the centre.

NoteSpurious entries and exits

This simple count is subject to a few spurious effects.

One arises from missing data: The centroid becomes NaN wherever the position could not be recovered after cleaning, and compute_region_occupancy marks those frames as False. A tracking gap that occurs while the mouse is clearly inside a region will therefore appear as a brief exit followed by a re-entry.

Another arises from boundary jitter: small fluctuations of the centroid around a region boundary generate an entry and an exit for every crossing.

Importantly, neither effect disrupts the entry–exit balance discussed above, since both introduce transitions in matched pairs. To mitigate these artefacts, we could discard visits shorter than a chosen minimum duration and handle gaps explicitly rather than counting them as time spent outside.

5.10 Solutions

Click each solution to reveal it.

↩︎ back to exercise

position_conf = filter_by_confidence(
    ds.position,
    ds.confidence,
    threshold=0.95,
    print_report=True,
)

position_clean = rolling_filter(
    position_conf,
    window=5,
    statistic="median",
    min_periods=2,
)
No missing points (marked as NaN) in input.
Missing points (marked as NaN) in output:

keypoint                    snout            left_ear           right_ear             centre         lateral_left        lateral_right           tailbase             tail_end
individual                                                                                                                                                                    
individual_0  4728/18485 (25.58%)  1671/18485 (9.04%)  1819/18485 (9.84%)  373/18485 (2.02%)  3795/18485 (20.53%)  3443/18485 (18.63%)  1478/18485 (8.0%)  3885/18485 (21.02%)

The snout keypoint shows the highest number of dropped samples, with tail_end following closely. This makes sense: the mouse’s snout is often occluded by its head when it pitches forward, and the tail end is a fine feature that can be challenging to resolve against the background.

↩︎ back to exercise

You can compare your geojson file with the set of regions we prepared earlier: EPM_rois.geojson. Since drawing regions is a manual task, there will be differences. To see how the boundaries compare, you can load both files into the GUI with “Load layer”.

↩︎ back to exercise

tailbase_occupancy = compute_region_occupancy(
    position_clean.sel(keypoint="tailbase"), rois
)
time_per_region_tailbase = (
    100 * tailbase_occupancy.mean("time")
).squeeze()

x = np.arange(time_per_region.sizes["region"])
fig, ax = plt.subplots()
ax.bar(x - 0.2, time_per_region.values, width=0.4, label="centroid")
ax.bar(x + 0.2, time_per_region_tailbase.values, width=0.4, label="tailbase")
ax.set_xticks(x, time_per_region.region.values, rotation=30)
ax.set_ylabel("% time spent in region")
ax.legend()

Time spent in each region, using the centroid or the tailbase to represent the mouse.

The time spent in the centre region increases when using the tailbase keypoint rather than the centroid, whereas the time spent in the open arms decreases. The closed arms show less variation.

This makes sense: the tailbase trails behind the mouse, so it is still in the centre while the snout and the rest of the keypoints in the body have already entered an arm. And the effect is strongest for the open arms, which the mouse tends to enter only partway before turning back.

Note as well that the tailbase accounts for noticeably fewer frames:

print(f"centroid: {float(time_per_region.sum()):.1f}%")
print(f"tailbase: {float(time_per_region_tailbase.sum()):.1f}%")
centroid: 98.2%
tailbase: 91.8%

The centroid is the average of up to 8 keypoints and is only NaN when all of them are missing, whereas a single tailbase keypoint missing makes that frame unusable.