Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
5aeb6ae
Patching rendering (missed in merge of compression/runtime refactor)
SkepticRaven Sep 25, 2025
fb3c02d
Adding standalone rendering of fecal boli data
SkepticRaven Sep 26, 2025
a93ab4e
Making typing match func call
SkepticRaven Sep 26, 2025
24b420c
First step of adding in feature data inspection
SkepticRaven Sep 30, 2025
347c188
Adding in new QC metric to inspect large pose data
SkepticRaven Oct 1, 2025
7ccd686
Deleting utils.pose and redirecting to new func def locations
SkepticRaven Oct 1, 2025
4a01bac
Adding cli interface to filter unassign identity when keypoints are t…
SkepticRaven Oct 1, 2025
efda379
Removing circular import
SkepticRaven Oct 1, 2025
7562c3e
Properly overwriting field
SkepticRaven Oct 1, 2025
6571f2b
Patching feature workflow update of var names
SkepticRaven Oct 2, 2025
66329ca
uv env config changes for ubuntu dev
SkepticRaven Oct 2, 2025
8b520d1
Merge branch 'main' into KLAUS-280-discard-pose-data-when-convex-hull…
SkepticRaven Oct 2, 2025
d0fc1e7
Also need to modify id_mask field
SkepticRaven Oct 2, 2025
9ebb131
Moving gpu library extras group
SkepticRaven Oct 3, 2025
fd07c3b
Merge branch 'main' into KLAUS-280-discard-pose-data-when-convex-hull…
SkepticRaven Oct 14, 2025
9ee9139
Blanking confidence values too
SkepticRaven Oct 15, 2025
2edd407
Merge branch 'main' into KLAUS-280-discard-pose-data-when-convex-hull…
SkepticRaven Oct 16, 2025
830c56c
Adding nextflow module implementation of filtering
SkepticRaven Oct 16, 2025
9c6b18d
Adding in segmentation filtering
SkepticRaven Oct 16, 2025
32ad1f6
Changing path locations to match expected location
SkepticRaven Oct 17, 2025
340efd2
Changing tests to match new command line args
SkepticRaven Oct 17, 2025
f28198a
Linting
SkepticRaven Oct 17, 2025
f48a632
Linting and adding plotnine
SkepticRaven Oct 17, 2025
3f64b48
Fix patch references in
bergsalex Oct 18, 2025
4b6ee5f
Update mock data
bergsalex Oct 18, 2025
d949686
Apply auto formatting~
bergsalex Oct 18, 2025
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
32 changes: 30 additions & 2 deletions nextflow/modules/pose_qc.nf
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@
* @param in_pose The input pose file
*
* @return Rendered video
*
* @publish ./qc Rendered pose video
*/
process RENDER_POSE {
label "tracking"
publishDir "compressed/pose/", mode:'copy'
publishDir "${params.pubdir}/qc", mode:'copy'

input:
tuple path(in_video), path(in_pose)
Expand All @@ -18,6 +20,32 @@ process RENDER_POSE {

script:
"""
python3 /kumar_lab_models/mouse-tracking-runtime/render_pose.py --in-vid ${in_video} --in-pose ${in_pose} --out-vid ${in_video.baseName}_pose.mp4
mouse-tracking utils render-pose ${in_video} ${in_pose} ${in_video.baseName}_pose.mp4
"""
}

/**
* Render fecal boli on a frame
*
* @param in_video The input video file
* @param in_pose The input pose file
*
* @return Rendered fecal boli video
*
* @publish ./qc Rendered fecal boli video
*/
process RENDER_BOLI {
label "tracking"
publishDir "${params.pubdir}/qc", mode:'copy'

input:
tuple path(in_video), path(in_pose)

output:
path "${in_video.baseName}_boli.avi"

script:
"""
mouse-tracking utils render-fecal-boli ${in_video} ${in_pose} ${in_video.baseName}_boli.avi
"""
}
31 changes: 30 additions & 1 deletion nextflow/modules/single_mouse.nf
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ process PREDICT_SINGLE_MOUSE_SEGMENTATION {
label "gpu"
label "tracking"
label "r_single_seg"

input:
tuple path(video_file), path(in_pose_file)

Expand Down Expand Up @@ -90,6 +90,35 @@ process QC_SINGLE_MOUSE {
"""
}

/**
* Modifies a pose file to filter out large poses.
*
* @param tuple
* - in_video The input video file
* - in_pose_file The input pose file to modify
*
* @return tuple files
* - Path to the video file.
* - Path to the filtered pose file.
*/
process FILTER_LARGE_POSES {
label "tracking"

input:
tuple path(in_video), path(in_pose_file)

output:
tuple path("${in_video.baseName}_filtered.${in_video.extension}"), path("${in_video.baseName}_filtered.h5"), emit: files

script:
"""
cp ${in_pose_file} ${in_video.baseName}_filtered.h5
ln -s ${in_video} ${in_video.baseName}_filtered.${in_video.extension}

mouse-tracking utils filter-large-area-pose ${in_video.baseName}_filtered.h5
"""
}

/**
* Clips a video and its corresponding pose file to a specified duration from the start.
*
Expand Down
12 changes: 8 additions & 4 deletions nextflow/workflows/single_mouse_pipeline.nf
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
* This module contains the single mouse tracking pipeline.
* It processes video input to track a single mouse.
*/
include { PREDICT_SINGLE_MOUSE_SEGMENTATION; PREDICT_SINGLE_MOUSE_KEYPOINTS; CLIP_VIDEO_AND_POSE } from "${projectDir}/nextflow/modules/single_mouse"
include { PREDICT_SINGLE_MOUSE_SEGMENTATION;
PREDICT_SINGLE_MOUSE_KEYPOINTS;
CLIP_VIDEO_AND_POSE;
FILTER_LARGE_POSES; } from "${projectDir}/nextflow/modules/single_mouse"
include { PREDICT_ARENA_CORNERS } from "${projectDir}/nextflow/modules/static_objects"
include { PREDICT_FECAL_BOLI } from "${projectDir}/nextflow/modules/fecal_boli"
include { QC_SINGLE_MOUSE } from "${projectDir}/nextflow/modules/single_mouse"
Expand Down Expand Up @@ -40,14 +43,15 @@ workflow SINGLE_MOUSE_TRACKING {
main:
// Generate pose files
pose_init = VIDEO_TO_POSE(input_video).files
// Pose v2 is output from this step
// Pose v2 is output from keypoint prediction step
pose_v2_data = PREDICT_SINGLE_MOUSE_KEYPOINTS(pose_init).files
if (params.align_videos) {
pose_v2_data = CLIP_VIDEO_AND_POSE(pose_v2_data, params.clip_duration).files
}
// Valid Pose v6 is produced when segmentation is added.
pose_and_seg_data = PREDICT_SINGLE_MOUSE_SEGMENTATION(pose_v2_data).files
// Completed Pose v6 is output from this step
pose_with_corners = PREDICT_ARENA_CORNERS(pose_and_seg_data).files
filtered_pose_v6 = FILTER_LARGE_POSES(pose_and_seg_data).files
pose_with_corners = PREDICT_ARENA_CORNERS(filtered_pose_v6).files
pose_v6_data = PREDICT_FECAL_BOLI(pose_with_corners).files

// Publish the pose v2 results
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies = [
"h5py>=3.11.0",
"pydantic-settings>=2.10.1",
"yacs>=0.1.8",
"plotnine>=0.12.0",
]

[project.optional-dependencies]
Expand All @@ -28,6 +29,7 @@ gpu = [
"torch==2.6.0",
"torchvision==0.21.0",
"torchaudio==2.6.0",
"nvidia-cusparselt-cu12==0.6.3",
]

# CPU-only convenience for local tests (unchanged idea)
Expand Down
19 changes: 19 additions & 0 deletions src/mouse_tracking/cli/qa.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ def single_pose(
)


@app.command()
def single_feature(
pose: Path = typer.Argument(..., help="Path to the pose file to inspect"),
behavior: Path = typer.Argument(..., help="Path to the behavior table to inspect"),
output: Path | None = typer.Option(
None, help="Output filename. Will append row if already exists."
),
):
"""Run single mouse feature inspection."""
# Dynamically set the output filename if not provided
if not output:
output = Path(
f"QA_features_{pose.stem}_{pd.Timestamp.now().strftime('%Y%m%d_%H%M%S')}.pdf"
)

# TODO implement desired plots of feature data for more in-depth inspection
raise NotImplementedError("Feature inspection is not yet implemented.")


@app.command()
def multi_pose():
"""Run multi pose quality assurance."""
Expand Down
52 changes: 51 additions & 1 deletion src/mouse_tracking/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@
from rich import print

from mouse_tracking import __version__
from mouse_tracking.core.config.pose_utils import PoseUtilsConfig
from mouse_tracking.matching.match_predictions import match_predictions
from mouse_tracking.pose import render
from mouse_tracking.pose.convert import downgrade_pose_file
from mouse_tracking.utils import fecal_boli, static_objects
from mouse_tracking.utils.clip_video import clip_video_auto, clip_video_manual
from mouse_tracking.utils.writers import (
downgrade_pose_file,
filter_large_contours,
filter_large_keypoints,
)

app = typer.Typer()
CONFIG = PoseUtilsConfig()


def version_callback(value: bool) -> None:
Expand Down Expand Up @@ -54,6 +60,27 @@ def aggregate_fecal_boli(
result.to_csv(output, index=False)


@app.command()
def render_fecal_boli_video(
in_video: Path = typer.Option(
..., "--in-video", help="Path to the input video file"
),
in_pose: Path = typer.Option(
..., "--in-pose", help="Path to the input HDF5 pose file"
),
out_video: Path = typer.Option(
..., "--out-video", help="Path to the output video file"
),
):
"""
Render fecal boli on video frames.

This command renders fecal boli from the pose file onto the input video.
Video playback is 1fps with original frame timestamp overlayed.
"""
fecal_boli.render_fecal_boli_video(str(in_video), str(in_pose), str(out_video))


clip_video_app = typer.Typer(help="Produce a video and pose clip aligned to criteria.")


Expand Down Expand Up @@ -227,3 +254,26 @@ def stitch_tracklets(
This command stitches tracklets from the specified source.
"""
match_predictions(in_pose)


@app.command()
def filter_large_area_pose(
in_pose: Path = typer.Argument(..., help="Input HDF5 pose file"),
max_area: int = typer.Option(
CONFIG.OFA_MAX_EXPECTED_AREA_PX,
help="Maximum area a pose can have, using a bounding box on keypoint pose.",
),
):
"""
Filer pose by area.

This command unmarks identity of pose (both keypoint and segmentation) with large areas.
"""
filter_large_keypoints(
in_pose,
max_area,
)
filter_large_contours(
in_pose,
max_area,
)
3 changes: 3 additions & 0 deletions src/mouse_tracking/core/config/pose_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ class PoseUtilsConfig(BaseSettings):
MIN_JABS_CONFIDENCE: float = 0.3
MIN_JABS_KEYPOINTS: int = 3

# Large animals are rarely larger than 100px in our OFA
OFA_MAX_EXPECTED_AREA_PX: int = 150 * 150

# Colors
MOUSE_COLORS: list[tuple[int, int, int]] = [
(228, 26, 28), # Red
Expand Down
56 changes: 0 additions & 56 deletions src/mouse_tracking/pose/convert.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
"""Pose data conversion utilities."""

import os
import re

import h5py
import numpy as np

from mouse_tracking.core.exceptions import InvalidPoseFileException
from mouse_tracking.utils.run_length_encode import run_length_encode
from mouse_tracking.utils.writers import write_pixel_per_cm_attr, write_pose_v2_data


def v2_to_v3(pose_data, conf_data, threshold: float = 0.3):
Expand Down Expand Up @@ -95,53 +89,3 @@ def multi_to_v2(pose_data, conf_data, identity_data):
return_list.append((cur_id, single_pose, single_conf))

return return_list


def downgrade_pose_file(pose_h5_path, disable_id: bool = False):
"""Downgrades a multi-mouse pose file into multiple single mouse pose files.

Args:
pose_h5_path: input pose file
disable_id: bool to disable identity embedding tracks (if available) and use tracklet data instead
"""
if not os.path.isfile(pose_h5_path):
raise FileNotFoundError(f"ERROR: missing file: {pose_h5_path}")
# Read in all the necessary data
with h5py.File(pose_h5_path, "r") as pose_h5:
if "version" in pose_h5["poseest"].attrs:
major_version = pose_h5["poseest"].attrs["version"][0]
else:
raise InvalidPoseFileException(
f"Pose file {pose_h5_path} did not have a valid version."
)
if major_version == 2:
print(f"Pose file {pose_h5_path} is already v2. Exiting.")
exit(0)

all_points = pose_h5["poseest/points"][:]
all_confidence = pose_h5["poseest/confidence"][:]
if major_version >= 4 and not disable_id:
all_track_id = pose_h5["poseest/instance_embed_id"][:]
elif major_version >= 3:
all_track_id = pose_h5["poseest/instance_track_id"][:]
try:
config_str = pose_h5["poseest/points"].attrs["config"]
model_str = pose_h5["poseest/points"].attrs["model"]
except (KeyError, AttributeError):
config_str = "unknown"
model_str = "unknown"
pose_attrs = pose_h5["poseest"].attrs
if "cm_per_pixel" in pose_attrs and "cm_per_pixel_source" in pose_attrs:
pixel_scaling = True
px_per_cm = pose_h5["poseest"].attrs["cm_per_pixel"]
source = pose_h5["poseest"].attrs["cm_per_pixel_source"]
else:
pixel_scaling = False

downgraded_pose_data = multi_to_v2(all_points, all_confidence, all_track_id)
new_file_base = re.sub("_pose_est_v[0-9]+\\.h5", "", pose_h5_path)
for animal_id, pose_data, conf_data in downgraded_pose_data:
out_fname = f"{new_file_base}_animal_{animal_id}_pose_est_v2.h5"
write_pose_v2_data(out_fname, pose_data, conf_data, config_str, model_str)
if pixel_scaling:
write_pixel_per_cm_attr(out_fname, px_per_cm, source)
Loading