Pose Estimation Metadata Structure#

This document describes the pose estimation metadata shape and the decisions that produced it. It is intended for developers who are contributing new interfaces or modifying existing ones, and the decisions below are the ones a new pose interface has to follow.

For user-facing documentation on how to annotate pose estimation data, see How to Annotate Pose Estimation Metadata. For the rules that hold across every modality, see Metadata Principles.

The Structure#

The pose-specific metadata lives under metadata["Pose"]. The objects it links out to live in the registries that own them, shared with the other modalities:

metadata["Devices"] = {
    "top_camera": {  # keyed by metadata_key; "name" is the NWB object's name
        "name": "CameraTop",
        "description": "Camera mounted above the arena.",
    },
}

metadata["Behavior"]["ExternalVideos"] = {
    "top_camera_video": {"name": "VideoTopCamera"},
}

metadata["Pose"] = {
    "Skeletons": {
        "deep_lab_cut": {
            "name": "SkeletonPoseEstimation",
            "nodes": ["snout", "left_ear", "right_ear"],   # the keypoints, in series order
            "edges": [[0, 1], [0, 2]],                     # optional, indices into "nodes"
            "subject": "mouse_1",                          # optional, an individual in the source,
                                                           #   not the NWB Subject's name
        },
    },
    "PoseEstimations": {
        "deep_lab_cut": {
            "name": "PoseEstimation",
            "description": "Tracking of a mouse in an open field.",
            "skeleton_metadata_key": "deep_lab_cut",       # -> Pose.Skeletons
            "device_metadata_key": "top_camera",           # -> Devices
            "source_video_metadata_key": "top_camera_video",   # -> Behavior.ExternalVideos
            "source_software": "DeepLabCut",
            "source_software_version": "2.3.8",
            "scorer": "DLC_resnet50_openfieldJan1shuffle1",
            "dimensions": [[640, 480]],                    # frame size, one row per video
            "PoseEstimationSeries": {
                "snout": {                                 # keyed by keypoint name
                    "name": "PoseEstimationSeriesSnout",
                    "description": "Position of the snout.",
                    "reference_frame": "(0,0) is the top-left corner of the frame.",
                    "unit": "pixels",
                    "confidence_definition": "Softmax output of the deep neural network.",
                },
            },
        },
    },
}

A skeleton entry requires name and nodes. Everything in a container entry is optional and is written only when present, including the entry’s own name and its PoseEstimationSeries. Those fall back one field at a time to placeholders that name the objects, so partial metadata is enough and the keypoint arrays alone are sufficient to write a valid file. The container entry itself is not optional: a metadata_key naming no entry raises, because that is a caller mistake and not absent metadata.

The cross-references resolve as follows. skeleton_metadata_key goes into metadata["Pose"]["Skeletons"], device_metadata_key into the shared top-level metadata["Devices"], and source_video_metadata_key and labeled_video_metadata_key into metadata["Behavior"]["ExternalVideos"]. A key naming no entry raises in all four cases.

A pose interface’s metadata_key addresses its container entry and, by default, its skeleton entry as well. The defaults are fixed snake_case constants: "lightning_pose", "deep_lab_cut_metadata_key". SLEAPInterface is the one derived case and uses f"sleap_{track_name}", because a multi-animal session instantiates it once per track. See The metadata_key parameter for the cross-modality rule.

Design Decisions#

One container per camera view per subject#

A PoseEstimation holds the keypoints of one subject as seen from one camera, and an interface writes one of them. The alternative was writing everything the source file holds. That is what SLEAPInterface did until #1965: it handed the whole .slp to sleap_io, and a multi-animal file came out holding several subjects in per-video processing modules.

The reason is the NWB data model and not a preference. An NWB file holds one Subject, and ndx-pose stores one subject per file, so a container holding two animals has nowhere to say which keypoints belong to whom. A format that carries several tracks or several views therefore names one of each. That is what SLEAPInterface’s track_name and video_name arguments are for, with get_available_tracks and get_available_videos to list them. The old behaviour still runs behind a FutureWarning until August 2027, delegating to the interface kept verbatim in _sleap_legacy.py.

A consequence worth knowing when writing a new interface: skeletons are reused by NWB name, so two containers pointing at entries with the same name link one Skeleton object. That is how two camera views of one subject share a skeleton instead of writing two copies of it.

The series are keyed by keypoint name and not by metadata_key#

Skeletons and PoseEstimations are addressed by metadata_key, one entry each per interface. The PoseEstimationSeries are not a third registry of that kind. They sit inside their container entry and their keys are the keypoint names read from the source, so a series is addressed as metadata["Pose"]["PoseEstimations"][metadata_key]["PoseEstimationSeries"][keypoint_name].

The interface chooses the first key and the tracker chooses the second. A keypoint name is already unique inside its container and it is what a user editing the metadata will look for, so a metadata_key layer there would be a handle nobody needs. Note that this is an exception to the plural-registry convention in Metadata Principles: PoseEstimationSeries reads like a keyed registry and is not one.

No placeholder camera and no placeholder skeleton#

A container entry naming no device is written without one, and the same holds for the skeleton. Neither gets the fabricated object that a missing required link gets elsewhere, such as PlaceholderMicroscope in ophys.

This follows the rule in Metadata Principles and lands on its optional branch, because ndx-pose declares both links optional. The pose-specific half is that the device is not a link users will usually fill either. No pose format records the camera it was filmed with, so device_metadata_key and the metadata["Devices"] entry behind it are always the user’s to supply.

The video is linked as an object, not written as a path#

source_video_metadata_key and labeled_video_metadata_key resolve through metadata["Behavior"]["ExternalVideos"] to the ImageSeries a video interface wrote, and the container links that object. ndx-pose also accepts original_videos and labeled_videos, the file paths the trackers themselves record, and those fields are kept for the formats that carry them.

The link is preferred because it is a reference into the file while a path is a string that may not travel with it, and because rly/ndx-pose#57 deprecates the path and dimension fields in favour of the link. The cost is an ordering constraint. The pose writer cannot create the ImageSeries. It belongs to a video interface, and that interface also decides whether it lands in acquisition or in the behavior processing module. Both are searched, and a key naming no object in the file raises and says the video interface has to be part of the same conversion and has to run first.