Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion examples/convert_lp_to_posemark.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,10 @@
# frames. This is necessary because the underlying loader (sleap-io)
# resolves image paths relative to the CSV location, so the split CSV
# must live alongside the frame images it references.
# The temporary directory is kept separate from ``posemark_base_dir``, and
# removed after conversion.

lp_session_base = posemark_base_dir / ".lp_sessions"
lp_session_base = Path(tempfile.mkdtemp(prefix="lp-sessions-"))
split_results = split_lp_collected_data(
input_path=source_project_dir / "CollectedData.csv",
output_dir=lp_session_base,
Expand Down Expand Up @@ -239,6 +241,9 @@
)
print("Done.\n")

# The per-session split files are no longer needed.
shutil.rmtree(lp_session_base)

# %%
# The resulting *PoseMark* dataset:

Expand Down
364 changes: 364 additions & 0 deletions examples/convert_sleap_to_posemark.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,364 @@
"""Convert SLEAP project to the PoseMark format
=============================================

Prepare a SLEAP project to be contributed
to the *PoseMark* corpus.
"""

# %%
# Imports
# -------
import json
import shutil
import tempfile
from datetime import datetime, timezone
from pathlib import Path

import imageio.v3 as iio
import sleap_io as sio

import poseinterface
from poseinterface.clips import extract_single_clip
from poseinterface.io import (
annotations_to_posemark,
predictions_to_posemark,
video_to_posemark,
)
from poseinterface.utils import tree

# %%
# Overview
# --------
# We'll handle the conversion in two steps:
#
# 1. **Convert:** SLEAP project files (videos, frame annotations, and
# keypoint predictions) are restructured into the
# :ref:`PoseMark format <target-posemark>`.
# 2. **Extract clips:** Short video clips and their labels are extracted
# from the converted videos and their corresponding keypoint
# predictions, ready for expert review.
#
# The workflow is similar to the one followed in
# :ref:`sphx_glr_auto_examples_convert_dlc_to_posemark.py`,
# with a few differences explained below.

# %%
# Source SLEAP project
# --------------------
# We work with a dataset from the
# `Sainsbury Wellcome Centre (SWC) <https://www.sainsburywellcome.org/>`_,
# produced by Loukia Katsouri from John O'Keefe's lab.
# It contains single-animal top-down videos of mice exploring an
# Elevated Plus Maze (EPM), analysed using
# `SLEAP <https://sleap.ai/>`_.
#
# .. note::
#
# This example runs against a lightweight fixture shipped with the
# repository (under ``tests/data/``). This fixture contains only a subset
# of the original project, and is intended for testing and demonstration
# purposes.
#
# Replace ``source_project_dir`` and ``posemark_base_dir`` with the paths
# to your SLEAP project and *PoseMark* directories, respectively. Keep
# in mind that your project will contain more files than are shown here.

source_project_dir = (
Path(".").resolve().parent
/ "tests"
/ "data"
/ "sleap"
/ "MouseTopDown-EPM"
)
print(tree(source_project_dir, level=1, exclude_hidden=True))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use level=2 to also show the predictions file names?

Suggested change
print(tree(source_project_dir, level=1, exclude_hidden=True))
print(tree(source_project_dir, level=2, exclude_hidden=True))

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the 2 other examples use level=1 - should all of these use 2?


# For this example we use a temporary directory, cleaned up at the end.
posemark_base_dir = Path(tempfile.mkdtemp(prefix="posemark-"))
print(f"\nPoseMark dataset will be saved to: {posemark_base_dir}")

# %%
# The SLEAP project differs from a DLC project in a few key respects:
#
# - All annotations live in a **single project-level** labels file
# (``labels.v001.slp``), which may cover multiple videos.
# - Labeled frames are not stored as image files; they are read directly
# from the videos referenced by the labels file.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is worth qualifying this statement a bit? That's true of 'standard' .slp files, but in .pkg.slp files the frames can be stored inside the file itself.

I wonder if at some point we need a helper in poseinterface.io that can take frames from a variety of sources (extracted from the video, taken from .pkg.slp, or directly as an image sequece) and write them in PoseMark format. That would also replcae the inline imegeio loop later in this example.

Not needed for this PR, but sth to keep in mind.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and opened #88

# - Predictions are stored per video in ``predictions/``, as
# ``.predictions.slp`` files.
#
# Let's load the labels file and inspect the videos it references.

labels = sio.load_file(source_project_dir / "labels.v001.slp")
print(labels)
for video in labels.videos:
print(video.filename)

# %%
# The video paths stored in the labels file are absolute paths on the
# machine where the project was created. If the project has since been moved,
# we need to point them to the videos' new location.

old_project_dir = "/home/user/sleap-projects/MouseTopDown-EPM"
labels.replace_filenames(
prefix_map={old_project_dir: source_project_dir.as_posix()}
)
for video in labels.videos:
print(video.filename, "exists:", video.exists())

# %%
# Define sessions to convert
# ---------------------------
# We select two sessions from the SLEAP project and assign each to either
# the ``Train`` or ``Test`` split of the
# :ref:`PoseMark <target-posemark>`.
# You may expand this list with more sessions, but ensure that each session
# belongs to exactly one split, and that the same subject doesn't appear in
# both splits (to avoid data leakage).
# All videos use the same top-down camera view (``cam-topdown``).

sessions = [
{
"split": "Train",
"source_video": "M727755_EPM_20200317_170544999-converted.mp4",
"sub_id": "M727755",
"ses_id": "20200317",
"cam_id": "topdown",
},
{
"split": "Test",
"source_video": "M708154_EPM_20200317_185651629-converted.mp4",
"sub_id": "M708154",
"ses_id": "20200317",
"cam_id": "topdown",
},
]

project_name = "SWC-plusmaze"

# %%
# Convert to PoseMark format
# ----------------------------
# For each session we:
#
# 1. copy (and re-encode, if necessary) the session video;
# 2. extract the session's user annotations from the labels file,
# convert them to COCO JSON, and save the labeled frames as images;
# 3. convert SLEAP predictions to COCO JSON.
#
# Since :func:`~poseinterface.io.annotations_to_posemark` expects
# annotations for a single video, we first extract each session's
# annotations and save them to a per-session ``.slp`` file in a temporary
# directory, kept separate from ``posemark_base_dir`` and removed
# automatically after conversion.

for session in sessions:
split = session["split"]
ids = {k: session[k] for k in ["sub_id", "ses_id", "cam_id"]}
sub_ses_prefix = f"sub-{ids['sub_id']}_ses-{ids['ses_id']}"
sub_ses_cam_prefix = f"{sub_ses_prefix}_cam-{ids['cam_id']}"
source_video_path = source_project_dir / "videos" / session["source_video"]
target_session_dir = (
posemark_base_dir / split / project_name / sub_ses_prefix
)
target_frames_dir = target_session_dir / "Frames"
target_frames_dir.mkdir(parents=True, exist_ok=True)

print(f"Converting session: {split}/{project_name}/{sub_ses_prefix}")
# Copy the session video, re-encoding to H.264/yuv420p if necessary
video_to_posemark(
input_video=source_video_path,
output_video_dir=target_session_dir,
**ids,
)
print(f"\tvideo: {source_video_path.name} -> {sub_ses_cam_prefix}.mp4")

# Extract this session's user annotations (dropping any predicted
# instances), save them to a temporary per-session labels file, and
# convert them to COCO frame labels JSON.
session_labels = labels.extract(source_video_path)
session_labels.remove_predictions()
with tempfile.TemporaryDirectory() as tmp_dir:
session_labels_path = Path(tmp_dir) / f"{source_video_path.stem}.slp"
session_labels.save(session_labels_path)
framelabels_path = annotations_to_posemark(
input_path=session_labels_path,
output_dir=target_frames_dir,
format="frame",
**ids,
)
Comment on lines +176 to +189

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each session's labels here have to take a detour through a temporary .slp file because annotations_to_posemark only accepts a file path.

I wonder if it's possible to have it also accept a labels object loaded with sleap-io. That would remove the need for this detour.

Could be also explored in a follow-up PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opened #89


# Save the labeled frames, read from the video, with standardised
# PoseMark filenames.
video = session_labels.video
for image in json.loads(framelabels_path.read_text())["images"]:
frame_idx = int(Path(image["file_name"]).stem.split("frame-")[-1])
iio.imwrite(target_frames_dir / image["file_name"], video[frame_idx])
Comment thread
lochhh marked this conversation as resolved.
Resolved
print(
f"\tannotations (+ frame images): {session_labels_path.name} "
f"-> {framelabels_path.name}"
)

# Convert SLEAP predictions to COCO video labels JSON for clip extraction.
# The default predictions output path varies depending on whether
# inference was run from the SLEAP GUI or the ``sleap predict`` command,
# and on its inputs. Here we assume predictions were generated from the
# GUI, which saves them to a ``predictions/`` folder alongside the labels
# file, and pick the most recent run. Adjust the directory and glob
# pattern below accordingly.
source_predictions_path = max(
(source_project_dir / "predictions").glob(
f"{source_video_path.name}*.slp"
),
default=None,
)
if source_predictions_path is None:
print(
f"\tNo predictions found for {source_video_path.stem!r} in "
f"{source_project_dir / 'predictions'}. Skipping predictions-to-"
"PoseMark conversion."
)
else:
predictions_to_posemark(
input_path=source_predictions_path,
video_path=source_video_path,
output_dir=target_session_dir,
**ids,
)
print(
f"\tpredictions: {source_predictions_path.name} -> "
f"{sub_ses_cam_prefix}_videolabels.json"
)
print("Done.\n")

# %%
# The resulting *PoseMark* dataset:

print(tree(posemark_base_dir, level=5))

# %%
# .. note::
#
# Frame labels (``framelabels.json``) are generated for both splits, but in
# the **published** dataset the ``Test`` split intentionally omits them for
# evaluation. See the :ref:`folder structure specification
# <target-posemark-folder-structure>` for details.
#
# The ``videolabels.json`` files generated alongside each session video are
# intermediate artifacts used for clip extraction in the next section, and
# will not be included in the published dataset.


# %%
# Extract clips
# -------------
# Clips (short video segments) can be extracted from the converted session
# videos. When the ``videolabels.json`` files are present, the corresponding
# clip label files (``cliplabels.json``) are generated automatically during
# clip extraction.
# These clip label files should then be proof-read and corrected by
# experts before being included in *PoseMark*.
#
# First, we specify the clip-extraction parameters. This step can be repeated
# with different parameters to incrementally expand the clip set.

duration = 5 # in frames
start_frames = [25, 50, 75]
print(f"Extracting {duration}-frame clips starting at frames: {start_frames}")

# %%
# We loop over all sessions and extract clips at each start frame.
# The resulting video clips and their ``cliplabels.json`` files are saved
# in a ``Clips/`` subdirectory within each session folder.

for session in sessions:
sub_ses_prefix = f"sub-{session['sub_id']}_ses-{session['ses_id']}"
sub_ses_cam_prefix = f"{sub_ses_prefix}_cam-{session['cam_id']}"
session_dir = (
posemark_base_dir / session["split"] / project_name / sub_ses_prefix
)

for start_frame in start_frames:
clip_path, _ = extract_single_clip(
video_path=session_dir / f"{sub_ses_cam_prefix}.mp4",
start_frame=start_frame,
duration=duration,
)
print(f"Extracted clip: {clip_path.stem}")


# %%
# The resulting *PoseMark* dataset, including the extracted clips and their
# corresponding labels:

print(tree(posemark_base_dir, level=5))


# %%
# .. note::
#
# In the published dataset, the ``Train`` split includes all
# ``cliplabels.json`` files, while the ``Test`` split omits them. Clip
# start labels (``startlabels.json``) are automatically derived during
# data submission from each clip's first frame, and are included in
# both splits to support point-tracker evaluation.
# The ``videolabels.json`` files generated in the previous section are
# intermediate artifacts used for clip extraction, and are never shared.
# See the :ref:`folder structure specification
# <target-posemark-folder-structure>` for details.


# %%
# Record provenance (optional)
# ----------------------------
# This step is optional and can be safely skipped, but it is highly recommended
# when converting real data, for book-keeping and reproducibility purposes.
#
# We save a copy of this script alongside a JSON sidecar with the
# ``poseinterface`` version (including git commit, via ``setuptools_scm``)
# and a UTC timestamp. Both files are written to a top-level
# ``.provenance/`` folder, named by project, so multiple projects under the
# same ``posemark_base_dir`` stay distinct.

# sphinx_gallery_capture_repr = ()
provenance_dir = posemark_base_dir / ".provenance"
provenance_dir.mkdir(parents=True, exist_ok=True)

# ``__file__`` is set when running this script directly with Python, but not
# when sphinx-gallery executes it during the docs build, guard accordingly.
script_path_str = globals().get("__file__")
if script_path_str:
shutil.copy(Path(script_path_str), provenance_dir / f"{project_name}.py")

(provenance_dir / f"{project_name}.json").write_text(
json.dumps(
{
"poseinterface_version": poseinterface.__version__,
"converted_at": datetime.now(timezone.utc).isoformat(),
"source_project_dir": str(source_project_dir),
},
indent=2,
)
)

# %%
# Clean up
# --------
# Since this example writes to a temporary directory, we remove it at the end.
#
# .. warning::
#
# Only run this cell when ``posemark_base_dir`` points to a temporary
# location. The guard below refuses to delete anything outside the system
# temp directory, so it is safe to leave in place when you adapt this
# example to a real *PoseMark* dataset path.

system_tempdir = Path(tempfile.gettempdir()).resolve()
target = posemark_base_dir.resolve()
if target.is_relative_to(system_tempdir) and target != system_tempdir:
shutil.rmtree(target)
print(f"Removed temporary PoseMark directory: {target}")
else:
print(
f"Refusing to remove {target}: not inside system temp dir "
f"({system_tempdir}). Delete manually if you really want to."
)
Binary file added tests/data/sleap/MouseTopDown-EPM/labels.v001.slp
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading