import warnings
from pathlib import Path
import numpy as np
import pandas as pd
from pydantic import FilePath, validate_call
from pynwb.file import NWBFile
from ..baseposeestimationinterface import BasePoseEstimationInterface
from ....tools.pose_estimation import _add_pose_estimation_to_nwbfile
from ....utils import DeepDict
[docs]
class DeepLabCutInterface(BasePoseEstimationInterface):
"""Data interface for DeepLabCut datasets."""
display_name = "DeepLabCut"
keywords = ("DLC", "DeepLabCut", "pose estimation", "behavior")
associated_suffixes = (".h5", ".csv")
info = "Interface for handling data from DeepLabCut."
_timestamps = None
_source_metadata = None
_animal_dataframe = None
[docs]
@classmethod
def get_source_schema(cls) -> dict:
source_schema = super().get_source_schema()
source_schema["properties"]["file_path"]["description"] = "Path to the file output by dlc (.h5 or .csv)."
source_schema["properties"]["config_file_path"]["description"] = "Path to .yml config file."
return source_schema
[docs]
def get_available_subjects(file_path: FilePath) -> list[str]:
"""
Extract available subjects from a DeepLabCut output file.
Parameters
----------
file_path : FilePath
Path to the DeepLabCut output file (.h5 or .csv).
Returns
-------
list[str]
List of subject names found in the file.
Raises
------
IOError
If the file is not a valid DeepLabCut output file.
FileNotFoundError
If the file does not exist.
"""
file_path = Path(file_path)
if not file_path.exists():
raise FileNotFoundError(f"File {file_path} does not exist.")
# Read the data
if ".h5" in file_path.suffixes:
df = pd.read_hdf(file_path)
elif ".csv" in file_path.suffixes:
df = pd.read_csv(file_path, header=[0, 1, 2], index_col=0)
else:
raise IOError(f"The file {file_path} passed in is not a valid DeepLabCut output data file.")
# Check if 'individuals' level exists in the column structure
if "individuals" in df.columns.names:
# Multi-subject file - extract unique individuals
individuals = df.columns.get_level_values("individuals").unique().tolist()
return individuals
else:
# Single-subject file - return default subject name
# For consistency with the interface's default behavior
return ["ind1"]
@validate_call
def __init__(
self,
file_path: FilePath,
*args, # TODO: change to * (keyword only) on or after August 2026
config_file_path: FilePath | None = None,
subject_name: str = "ind1",
pose_estimation_metadata_key: str | None = None,
verbose: bool = False,
metadata_key: str | None = None,
sampling_frequency: float | None = None,
):
"""
Interface for writing DeepLabCut's output files to NWB.
This interface reads DeepLabCut output files (.h5 or .csv) and converts them to NWB format
using the ndx-pose extension. It extracts keypoints (bodyparts), their coordinates, and confidence
values, and organizes them into a structured format within the NWB file.
Parameters
----------
file_path : FilePath
Path to the file output by DeepLabCut (.h5 or .csv). The file should contain the pose estimation
data with keypoints, coordinates, and confidence values.
config_file_path : FilePath, optional
Path to the DeepLabCut .yml config file. If provided, additional metadata such as video dimensions,
task description, and experimenter information will be extracted.
subject_name : str, default: "ind1"
The subject name to be used in the metadata. For output files with multiple individuals,
this must match the name of the individual for which the data will be added. This name is also
used to link the skeleton to the subject in the NWB file.
pose_estimation_metadata_key : str, optional
Deprecated. Renamed to ``metadata_key``; passing it forwards the value to ``metadata_key``
and will be removed on or after February 2027. Passing both raises ``ValueError``.
verbose : bool, default: False
Controls verbosity of the conversion process.
metadata_key : str, optional
The registry key under which this interface's metadata is stored in the dict-based format.
When ``None`` it resolves to a default (``"deep_lab_cut_metadata_key"``). The key is an
internal handle and does not appear in the NWB file; rename NWB objects via their ``name``
fields in the metadata dict instead. To opt into the dict-based shape, call
``get_metadata(use_new_metadata_format=True)``.
sampling_frequency : float, optional
The frame rate of the video the pose was estimated from, in Hz. A DeepLabCut output file's rows
are video frames and carry no times, and neither the file nor the project config records the
rate, so one of ``sampling_frequency`` or ``set_aligned_timestamps`` is required before writing.
Pass this for a constant frame rate; call ``set_aligned_timestamps`` when the frames have times
of their own, from a hardware clock or an alignment against another stream.
Metadata Structure
------------------
With ``use_new_metadata_format=True`` the metadata follows the unified, dict-based layout: a
shared top-level ``Devices`` registry plus the pose registries under the top-level
``metadata["Pose"]`` modality, cross-referenced by key.
.. code-block:: python
metadata = {
"Devices": {
"deep_lab_cut_metadata_key": { # registry key (snake_case, never written to the file)
"name": "CameraPoseEstimationDeepLabCut",
"description": "Camera used for behavioral recording and pose estimation.",
}
},
"Pose": {
"Skeletons": {
"deep_lab_cut_metadata_key": {
"name": "SkeletonPoseEstimationDeepLabCut_SubjectName",
"nodes": ["bodypart1", "bodypart2", ...], # keypoints/bodyparts
"edges": [[0, 1], [1, 2], ...], # connections between nodes (optional)
"subject": "subject_name", # links the skeleton to the subject
}
},
"PoseEstimations": {
"deep_lab_cut_metadata_key": { # keyed by metadata_key
"name": "PoseEstimationDeepLabCut",
"source_software": "DeepLabCut",
"scorer": "...",
"dimensions": [[height, width]],
"original_videos": ["path/to/video.mp4"],
"device_metadata_key": "deep_lab_cut_metadata_key", # -> metadata["Devices"]
"skeleton_metadata_key": "deep_lab_cut_metadata_key", # -> Pose.Skeletons
"PoseEstimationSeries": {
"bodypart1": {"name": "PoseEstimationSeriesBodypart1"},
"bodypart2": {"name": "PoseEstimationSeriesBodypart2"},
# one entry per bodypart; the dict key is the bodypart name
},
}
},
},
}
The registry key (``deep_lab_cut_metadata_key`` above) is an internal handle that never appears in
the NWB file; rename NWB objects through their ``name`` fields instead. ``get_metadata`` emits only
values extracted from the DeepLabCut source plus the object ``name``s; per-series defaults
(``description``, ``unit``, ``reference_frame``, ``confidence_definition``) are applied by the writer
at write time, so set them here only to override.
The metadata can be customized by:
#. Calling ``get_metadata(use_new_metadata_format=True)`` to retrieve the default metadata
#. Modifying the returned dictionary as needed
#. Passing the modified metadata to add_to_nwbfile() or run_conversion()
See also our `Conversion Gallery <https://neuroconv.readthedocs.io/en/main/conversion_examples_gallery/behavior/deeplabcut.html>`_
for more examples using DeepLabCut data.
Notes
-----
- When the subject_name matches a subject_id in the NWBFile, the skeleton will be automatically
linked to that subject.
"""
# Handle deprecated positional arguments
if args:
parameter_names = [
"config_file_path",
"subject_name",
"pose_estimation_metadata_key",
"verbose",
]
num_positional_args_before_args = 1 # file_path
if len(args) > len(parameter_names):
raise TypeError(
f"__init__() takes at most {len(parameter_names) + num_positional_args_before_args + 1} positional arguments but "
f"{len(args) + num_positional_args_before_args + 1} were given. "
"Note: Positional arguments are deprecated and will be removed on or after August 2026. "
"Please use keyword arguments."
)
positional_values = dict(zip(parameter_names, args))
passed_as_positional = list(positional_values.keys())
warnings.warn(
f"Passing arguments positionally to DeepLabCutInterface.__init__() is deprecated "
f"and will be removed on or after August 2026. "
f"The following arguments were passed positionally: {passed_as_positional}. "
"Please use keyword arguments instead.",
FutureWarning,
stacklevel=2,
)
config_file_path = positional_values.get("config_file_path", config_file_path)
subject_name = positional_values.get("subject_name", subject_name)
pose_estimation_metadata_key = positional_values.get(
"pose_estimation_metadata_key", pose_estimation_metadata_key
)
verbose = positional_values.get("verbose", verbose)
# This import is to assure that the ndx_pose is in the global namespace when an pynwb.io object is created
from importlib.metadata import version
import ndx_pose # noqa: F401
from packaging import version as version_parse
ndx_pose_version = version("ndx-pose")
if version_parse.parse(ndx_pose_version) < version_parse.parse("0.2.0"):
raise ImportError(
"DeepLabCut interface requires ndx-pose version 0.2.0 or later. "
f"Found version {ndx_pose_version}. Please upgrade: "
"pip install 'ndx-pose>=0.2.0'"
)
from ._dlc_utils import _read_config
file_path = Path(file_path)
suffix_is_valid = ".h5" in file_path.suffixes or ".csv" in file_path.suffixes
if not suffix_is_valid:
raise IOError(
"The file passed in is not a valid DeepLabCut output data file. Only .h5 and .csv are supported."
)
if metadata_key is not None and pose_estimation_metadata_key is not None:
raise ValueError(
"Pass only 'metadata_key'. 'pose_estimation_metadata_key' has been renamed to "
"'metadata_key' and the two cannot be combined."
)
if pose_estimation_metadata_key is not None:
warnings.warn(
"The 'pose_estimation_metadata_key' argument has been renamed to 'metadata_key' and "
"will be removed on or after February 2027. Please use 'metadata_key' instead.",
DeprecationWarning,
stacklevel=2,
)
metadata_key = pose_estimation_metadata_key
self.config_dict = dict()
if config_file_path is not None:
self.config_dict = _read_config(config_file_path=config_file_path)
self.subject_name = subject_name
self.verbose = verbose
# What the user passed, kept because the legacy shape defaults the key to the container name it
# writes rather than to the registry key the dict-based shape uses. It goes with the legacy shape.
self._user_metadata_key = metadata_key
self.metadata_key = metadata_key or "deep_lab_cut_metadata_key"
self.sampling_frequency = sampling_frequency
self.pose_estimation_container_kwargs = dict()
super().__init__(file_path=file_path, config_file_path=config_file_path)
def _get_metadata_schema_old_format(self) -> dict:
from ....utils import get_base_schema
metadata_schema = super().get_metadata_schema()
# Define the schema for PoseEstimation metadata
metadata_schema["properties"]["PoseEstimation"] = get_base_schema(tag="PoseEstimation")
# Add Skeletons schema
skeleton_schema = get_base_schema(tag="Skeletons")
skeleton_schema["additionalProperties"] = {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Name of the skeleton"},
"nodes": {
"type": "array",
"items": {"type": "string"},
"description": "List of node names (bodyparts)",
},
"edges": {
"type": ["array", "null"],
"items": {
"type": "array",
"items": {"type": "integer"},
"minItems": 2,
"maxItems": 2,
},
"description": "List of edges connecting nodes, each edge is a pair of node indices",
},
"subject": {
"type": ["string", "null"],
"description": "Subject ID associated with this skeleton",
},
},
"required": ["name", "nodes"],
}
# Add Devices schema
devices_schema = get_base_schema(tag="Devices")
devices_schema["additionalProperties"] = {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name of the device",
},
"description": {
"type": "string",
"description": "Description of the device",
},
},
"required": ["name"],
}
# Add PoseEstimationContainers schema
containers_schema = get_base_schema(tag="PoseEstimationContainers")
containers_schema["additionalProperties"] = {
"type": "object",
"description": "Metadata for a PoseEstimation group corresponding to one subject/session",
"properties": {
"name": {
"type": "string",
"description": "Name of the PoseEstimation group",
"default": "PoseEstimationDeepLabCut",
},
"description": {
"type": ["string", "null"],
"description": "Description of the pose estimation procedure and output",
},
"source_software": {
"type": ["string", "null"],
"description": "Name of the software tool used",
"default": "DeepLabCut",
},
"source_software_version": {
"type": ["string", "null"],
"description": "Version string of the software tool used",
},
"scorer": {
"type": ["string", "null"],
"description": "Name of the scorer or algorithm used",
},
"dimensions": {
"type": ["array", "null"],
"description": "Dimensions [height, width] of the labeled video(s)",
"items": {"type": "array", "items": {"type": "integer"}},
},
"original_videos": {
"type": ["array", "null"],
"description": "Paths to the original video files",
"items": {"type": "string"},
},
"labeled_videos": {
"type": ["array", "null"],
"description": "Paths to the labeled video files",
"items": {"type": "string"},
},
"skeleton": {
"type": ["string", "null"],
"description": "Reference to a Skeleton defined in Skeletons",
},
"devices": {
"type": ["array", "null"],
"description": "References to Device objects used to record the videos",
"items": {"type": "string"},
},
"PoseEstimationSeries": {
"type": ["object", "null"],
"description": "Dictionary of PoseEstimationSeries, one per body part",
"additionalProperties": {
"type": "object",
"properties": {
"name": {
"type": ["string", "null"],
"description": "Name for this series, typically the body part",
},
"description": {
"type": ["string", "null"],
"description": "Description for this specific series",
},
"unit": {
"type": ["string", "null"],
"description": "Unit of measurement (default: pixels)",
"default": "pixels",
},
"reference_frame": {
"type": ["string", "null"],
"description": "Description of the reference frame",
"default": "(0,0) corresponds to the bottom left corner of the video.",
},
"confidence_definition": {
"type": ["string", "null"],
"description": "How the confidence was computed (e.g., Softmax output)",
"default": "Softmax output of the deep neural network.",
},
},
"required": ["name"],
},
},
},
"required": ["name"],
}
# Add all schemas to the PoseEstimation schema
metadata_schema["properties"]["PoseEstimation"]["properties"] = {
"Skeletons": skeleton_schema,
"Devices": devices_schema,
"PoseEstimationContainers": containers_schema,
}
return metadata_schema
def _get_source_metadata(self) -> dict:
"""Read the output file and the project config once, and cache what they describe."""
if self._source_metadata is not None:
return self._source_metadata
from ._dlc_utils import (
_ensure_individuals_in_header,
_get_edges_from_config,
_get_graph_edges,
_get_video_info_from_config_file,
)
# Extract information from the DeepLabCut data
file_path = self.source_data["file_path"]
# Read the data to extract bodyparts
if ".h5" in Path(file_path).suffixes:
df = pd.read_hdf(file_path)
elif ".csv" in Path(file_path).suffixes:
df = pd.read_csv(file_path, header=[0, 1, 2], index_col=0)
# Ensure individuals in header if needed
df = _ensure_individuals_in_header(df, self.subject_name)
# This individual's bodyparts, not the file's. A multi-animal project can also declare unique
# bodyparts, landmarks of the scene rather than of any subject, and those arrive under an
# ``individuals`` group named ``single``. The file's set is then a superset of the subject's, so
# reading it here while the series come from ``_read_animal_dataframe`` wrote a skeleton whose
# nodes were not the keypoints the container held.
bodyparts = self._read_animal_dataframe().columns.get_level_values("bodyparts").unique().tolist()
# Get video dimensions from config if available
dimensions = None
if self.source_data.get("config_file_path"):
video_name = Path(file_path).stem.split("DLC")[0]
_, image_shape = _get_video_info_from_config_file(
config_file_path=self.source_data["config_file_path"], vidname=video_name
)
if image_shape is not None:
try:
shape_parts = [int(x.strip()) for x in image_shape.split(",")]
if len(shape_parts) == 4:
dimensions = [[shape_parts[3], shape_parts[1]]] # [[height, width]]
except (ValueError, IndexError):
pass
# Get edges from metadata pickle file if available
# The project config states the skeleton as pairs of bodypart names, which is the source that is
# actually there: the part affinity field graph below lives in a ``_meta.pickle`` beside the output
# file that DeepLabCut does not always write, and when it is missing the skeleton is written with
# nodes and no connections.
edges = _get_edges_from_config(config_dict=self.config_dict, bodyparts=bodyparts)
if not edges:
try:
filename = str(Path(file_path).parent / Path(file_path).stem)
for i, c in enumerate(filename[::-1]):
if c.isnumeric():
break
if i > 0:
filename = filename[:-i]
metadata_file_path = Path(filename + "_meta.pickle")
edges = _get_graph_edges(metadata_file_path=metadata_file_path)
except Exception:
pass
# The scorer is written into the file by DeepLabCut, so read it from there rather than off the
# filename. A multi-animal run appends a tracker suffix to the name (``_el`` for ellipse, ``_bx``
# for box, ``_sk`` for skeleton, plus ``_filtered``), and any local rename adds more, all of which
# the split swept into the scorer. It also raised ``ValueError`` on a stem holding "DLC" twice,
# which a video named after a DeepLabCut project produces.
scorer = df.columns.get_level_values("scorer")[0]
# The filename stays the only source for the video name, which is what looks the recording up in
# the project config.
file_stem = Path(file_path).stem
video_name = file_stem.split("DLC")[0] if "DLC" in file_stem else file_stem
# Get video info from config file if available
video_file_path = None
if self.source_data.get("config_file_path"):
video_file_path, _ = _get_video_info_from_config_file(
config_file_path=self.source_data["config_file_path"], vidname=video_name
)
self._source_metadata = dict(
bodyparts=bodyparts,
edges=edges,
dimensions=dimensions,
scorer=scorer,
video_file_path=video_file_path,
)
return self._source_metadata
def _get_keypoint_names(self) -> list[str]:
return self._get_source_metadata()["bodyparts"]
def _get_keypoint_data(self) -> dict[str, tuple[np.ndarray, np.ndarray | None]]:
df_animal = self._read_animal_dataframe()
keypoint_data = {}
for keypoint in df_animal.columns.get_level_values("bodyparts").unique():
data = df_animal.xs(keypoint, level="bodyparts", axis=1).to_numpy()
keypoint_data[keypoint] = (data[:, :2], data[:, 2])
return keypoint_data
def _add_config_metadata(self, metadata: DeepDict) -> None:
"""Take the task description and the experimenter from the project config, when there is one."""
if self.config_dict:
metadata["NWBFile"].update(
session_description=self.config_dict["Task"],
experimenter=[self.config_dict["scorer"]],
)
# TODO: remove with the legacy metadata["PoseEstimation"] block.
def _get_legacy_metadata(self) -> DeepDict:
"""The parsed components arranged into the top-level ``metadata["PoseEstimation"]`` block.
Built from ``_get_base_metadata`` rather than from ``get_metadata`` so it never carries the
dict-based registries: ``add_to_nwbfile`` dispatches on a top-level "Pose" block being present.
"""
metadata = self._get_base_metadata()
self._add_config_metadata(metadata=metadata)
source_metadata = self._get_source_metadata()
bodyparts = source_metadata["bodyparts"]
video_file_path = source_metadata["video_file_path"]
container_name = self._user_metadata_key or "PoseEstimationDeepLabCut"
skeleton_name = f"Skeleton{container_name}_{self.subject_name.capitalize()}"
device_name = f"Camera{container_name}"
pose_estimation_metadata = DeepDict()
pose_estimation_metadata["Skeletons"] = {
skeleton_name: {
"name": skeleton_name,
"nodes": bodyparts,
"edges": source_metadata["edges"],
"subject": self.subject_name,
}
}
pose_estimation_metadata["Devices"] = {
device_name: {
"name": device_name,
"description": "Camera used for behavioral recording and pose estimation.",
}
}
pose_estimation_metadata["PoseEstimationContainers"] = {
container_name: {
"name": container_name,
"description": "2D keypoint coordinates estimated using DeepLabCut.",
"source_software": "DeepLabCut",
"dimensions": source_metadata["dimensions"],
"skeleton": skeleton_name,
"devices": [device_name],
"scorer": source_metadata["scorer"],
"original_videos": [video_file_path] if video_file_path else None,
"PoseEstimationSeries": {
bodypart: {
"name": f"PoseEstimationSeries{bodypart.capitalize()}",
"description": f"Pose estimation series for {bodypart}.",
"unit": "pixels",
"reference_frame": "(0,0) corresponds to the bottom left corner of the video.",
"confidence_definition": "Softmax output of the deep neural network.",
}
for bodypart in bodyparts
},
}
}
metadata["PoseEstimation"] = pose_estimation_metadata
return metadata
[docs]
def get_original_timestamps(self) -> np.ndarray:
raise NotImplementedError(
"Unable to retrieve the original unaltered timestamps for this interface! "
"Define the `get_original_timestamps` method for this interface."
)
[docs]
def get_timestamps(self) -> np.ndarray:
raise NotImplementedError(
"Unable to retrieve timestamps for this interface! Define the `get_timestamps` method for this interface."
)
[docs]
def set_aligned_timestamps(self, aligned_timestamps: list | np.ndarray):
"""
Set aligned timestamps vector for DLC data with user defined timestamps
Parameters
----------
aligned_timestamps : list, np.ndarray
A timestamps vector.
"""
self._timestamps = np.asarray(aligned_timestamps)
def _read_animal_dataframe(self):
"""Read the output file and select this interface's individual, once."""
if self._animal_dataframe is not None:
return self._animal_dataframe
from ._dlc_utils import _ensure_individuals_in_header
file_path = Path(self.source_data["file_path"])
if ".h5" in file_path.suffixes:
df = pd.read_hdf(file_path)
elif ".csv" in file_path.suffixes:
df = pd.read_csv(file_path, header=[0, 1, 2], index_col=0)
df = _ensure_individuals_in_header(df, self.subject_name)
self._animal_dataframe = df.xs(self.subject_name, level="individuals", axis=1)
return self._animal_dataframe
[docs]
def add_to_nwbfile(
self,
nwbfile: NWBFile,
metadata: dict | None = None,
):
"""
Conversion from DLC output files to nwb. Derived from dlc2nwb library.
Parameters
----------
nwbfile: NWBFile
nwb file to which the recording information is to be added
metadata: dict
metadata info for constructing the nwb file (optional).
"""
# Dispatch on the legacy block being there rather than on the dict-based one being absent, since
# metadata that mentions neither is not legacy, it is a caller who said nothing about pose. The
# legacy shape is converted so there is a single write path, and the caller's metadata is passed
# on as they wrote it; only an absent one falls back to this interface's own.
# TODO: remove the branch with the legacy metadata["PoseEstimation"] block.
uses_legacy_metadata_format = metadata is not None and "PoseEstimation" in metadata
describes_pose = metadata is not None and ("Pose" in metadata or uses_legacy_metadata_format)
# Per block, not per field: a caller who wrote a pose block gets it as they wrote it, absent keys
# and all, and a caller who wrote none gets this interface's own rather than an error.
resolved_metadata = metadata if describes_pose else self.get_metadata()
df_animal = self._read_animal_dataframe()
# Get timestamps. A DeepLabCut file's index is the video frame number, so it becomes a time only
# once a frame rate is known. Neither the .h5/.csv nor the project config records one, so it has
# to come from the caller.
timestamps = self._timestamps
if timestamps is None:
if self.sampling_frequency is None:
raise ValueError(
"No timing information is available for this DeepLabCut output. Its rows are video "
"frames, and neither the file nor the project config records the frame rate, so the "
"times cannot be derived from the source. Pass 'sampling_frequency' to "
"DeepLabCutInterface for a constant frame rate, or call 'set_aligned_timestamps' with "
"one time per frame."
)
timestamps = np.asarray(df_animal.index) / self.sampling_frequency
metadata_key = (
(self._user_metadata_key or "PoseEstimationDeepLabCut")
if uses_legacy_metadata_format
else self.metadata_key
)
if uses_legacy_metadata_format:
resolved_metadata = self._translate_legacy_metadata(metadata=resolved_metadata, metadata_key=metadata_key)
_add_pose_estimation_to_nwbfile(
nwbfile=nwbfile,
keypoint_data=self._get_keypoint_data(),
timestamps=timestamps,
metadata=resolved_metadata,
metadata_key=metadata_key,
)
# TODO: remove with the legacy metadata["PoseEstimation"] block.
def _translate_legacy_metadata(self, metadata: dict, metadata_key: str) -> DeepDict:
"""Convert the legacy ``metadata["PoseEstimation"]`` block into the dict-based shape.
A re-nesting rather than a rewrite: the legacy block already keys its containers, skeletons and
devices and carries every field, but names its cross-references by object name where the
dict-based shape names them by registry key.
"""
legacy_metadata = metadata["PoseEstimation"]
container_entry = dict(legacy_metadata["PoseEstimationContainers"][metadata_key])
skeleton_name = container_entry.pop("skeleton", None)
device_names = container_entry.pop("devices", None)
translated = DeepDict({key: value for key, value in metadata.items() if key != "PoseEstimation"})
if skeleton_name is not None:
container_entry["skeleton_metadata_key"] = metadata_key
translated["Pose"]["Skeletons"][metadata_key] = legacy_metadata["Skeletons"][skeleton_name]
if device_names:
container_entry["device_metadata_key"] = metadata_key
translated["Devices"][metadata_key] = legacy_metadata["Devices"][device_names[0]]
translated["Pose"]["PoseEstimations"][metadata_key] = container_entry
return translated