Tool-using mice - Lockbox#
Dataset from Reiske et al. [2026], containing video and pose files of individual mice solving mechanical puzzle “lockboxes” recorded from three camera perspectives (top, front, side).
Full dataset: https://doi.org/10.14279/depositonce-23850
Subset of dataset (used here): https://www.dropbox.com/scl/fo/h7nkai8574h23qfq9m1b2/AP4gNZOpDJJ7z0yGtbWQiOc?rlkey=w36jzxqjkghg0j0xva5zsxy2v&st=5r9msqjw&dl=0
From Fig. 1 in Reiske et al. [2026].
import zipfile
from pathlib import Path
import pandas as pd
import requests
import xarray as xr
from movement.io import load_poses, save_poses
from movement.kinematics import compute_speed, compute_velocity
from movement.utils.vector import compute_norm
import ethograph as eto
from ethograph.io.pairing import pair_media
Download data#
def download_and_extract(url: str, data_folder: Path) -> None:
zip_path = data_folder / "dataset.zip"
data_folder.mkdir(parents=True, exist_ok=True)
response = requests.get(url, stream=True)
response.raise_for_status()
with open(zip_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(data_folder)
zip_path.unlink()
try:
_here = Path(__vsc_ipynb_file__).parent
except NameError:
_here = Path().resolve()
data_folder = _here.parent / "data" / "lockbox"
url = "https://www.dropbox.com/scl/fo/h7nkai8574h23qfq9m1b2/AP4gNZOpDJJ7z0yGtbWQiOc?rlkey=w36jzxqjkghg0j0xva5zsxy2v&e=1&st=5r9msqjw&dl=1"
download_and_extract(url, data_folder)
data_folder = data_folder / "labeled"
Build feature datasets#
Convert each trial’s DeepLabCut .h5 tracks to CSV (so the GUI and the alignment cell below can reference them), compute kinematic features, and save the merged lockbox.nc.
fps = 30
cameras = ["front-view", "side-view", "top-down-view"]
trials = [
"2021-02-15_07-32-44_segment1_mouse324",
# "2021-03-05_08-36-42_segment1_mouse324",
# "2021-05-24_07-36-05_segment1_mouse291",
# "2021-05-25_08-19-50_segment2_mouse291",
"2021-05-31_07-34-21_segment2_mouse291",
"2021-05-31_07-34-21_segment3_mouse291",
]
ds_list = []
for trial in trials:
trial_datasets: dict[str, xr.Dataset] = {}
for file in sorted(data_folder.glob(f"{trial}*-tracks.h5")):
name = file.name
df = pd.read_hdf(file)
ds = load_poses.from_dlc_style_df(df, fps=fps)
# Save CSV so the GUI and the alignment cell below can reference the tracks.
csv_path = str(file).replace(".h5", ".csv")
save_poses.to_dlc_file(ds, csv_path)
if "front-view" in name:
ds["front_velocity"] = compute_velocity(ds.position)
ds["front_speed"] = compute_speed(ds.position)
trial_datasets["front-view"] = ds
elif "top-down-view" in name:
head_centre_pos = ds.position.sel(keypoint=["ear_left", "ear_right"]).mean("keypoint")
ds["topview_distance_head_lever_tip"] = compute_norm(
ds.position.sel(keypoint="lever_tip") - head_centre_pos
)
ds["topview_distance_head_stick_head"] = compute_norm(
ds.position.sel(keypoint="stick_head") - head_centre_pos
)
ds["topview_distance_head_ball"] = compute_norm(ds.position.sel(keypoint="ball") - head_centre_pos)
trial_datasets["top-down-view"] = ds
elif "side-view" in name:
trial_datasets["side-view"] = ds
if not trial_datasets:
continue
ds_merged = xr.merge(trial_datasets.values(), compat="override")
if "top-down-view" in trial_datasets:
ds_merged["position"] = trial_datasets["top-down-view"]["position"]
ds_merged.attrs["trial"] = trial
ds_list.append(ds_merged)
dt = eto.from_datasets(ds_list)
dt.save(data_folder / "lockbox.nc")
print(f"Saved to {data_folder / 'lockbox.nc'}")
Saved to c:\Users\Admin\Documents\Akseli\Code\ethograph\data\lockbox\labeled\lockbox.nc
Build NWB alignment#
Discover the videos and the pose CSVs written above for each trial/camera and write the per-trial alignment.nwb. Trial durations are inferred from the media (no start_time/stop_time needed).
# Filenames carry a "_<object>_" infix (ball / sliding-door / stick / lever)
# between the trial id and the camera, so discover media by glob, not exact name.
video_by_trial: dict[str, dict[str, str]] = {}
pose_by_trial: dict[str, dict[str, str]] = {}
for trial in trials:
for cam in cameras:
videos = sorted(data_folder.glob(f"{trial}_*{cam}.avi"))
if videos:
video_by_trial.setdefault(trial, {})[cam] = str(videos[0])
# CSV tracks written by the feature cell above (movement appends _individual_<id>).
poses = sorted(data_folder.glob(f"{trial}_*{cam}-tracks*.csv"))
if poses:
pose_by_trial.setdefault(trial, {})[cam] = str(poses[0])
session_table = pd.DataFrame({"trial": trials})
for cam in cameras:
session_table[f"video_{cam}"] = [video_by_trial.get(t, {}).get(cam, "") for t in trials]
session_table[f"pose_{cam}"] = [pose_by_trial.get(t, {}).get(cam, "") for t in trials]
session_table = session_table.loc[:, (session_table != "").any()]
nwb_path = data_folder / ".ethograph" / "alignment.nwb"
pair_media(
trial_table=session_table,
stream_rates={"video": float(fps), "pose": float(fps)},
output_path=nwb_path,
pose_fps=float(fps),
)
print(f"Saved alignment to {nwb_path}")
Saved alignment to c:\Users\Admin\Documents\Akseli\Code\ethograph\data\lockbox\labeled\.ethograph\alignment.nwb