Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ After installation, the following commands are available in your environment:
- `jabs-classify` — run a trained classifier
- `jabs-stats` — print accuracy statistics for a classifier
- `jabs-export-training` — export training data from an existing JABS project
- `jabs-cli` - collection of smaller command line utilities

You can view usage information for any command with:

Expand Down
71 changes: 71 additions & 0 deletions src/jabs/project/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from datetime import datetime
from pathlib import Path

import h5py
import numpy as np
import pandas as pd

Expand Down Expand Up @@ -157,6 +158,29 @@ def session_tracker(self) -> SessionTracker | None:
"""get the session tracker for this project"""
return self._session_tracker

@staticmethod
def is_valid_project_directory(directory: Path) -> bool:
"""Check if a directory is a valid JABS project directory.

Currently just checks for the existence of the jabs directory and project.json file. This can be
called before initializing a Project object, which will create these files if they do not exist which
might not be desired behavior for tools that expect to operate on an existing JABS project directory.

Args:
directory: Path to the directory to check.

Returns:
True if the directory is a valid JABS project directory, False otherwise.
"""
try:
paths = ProjectPaths(directory)
if not paths.jabs_dir.exists() or not paths.project_file.exists():
return False
except (ValueError, FileNotFoundError):
return False

return True

def load_pose_est(self, video_path: Path) -> PoseEstimation:
"""return a PoseEstimation object for a given video path

Expand Down Expand Up @@ -646,3 +670,50 @@ def count_labels(
}

return counts

def rename_behavior(self, old_name: str, new_name: str) -> None:
"""Rename a behavior throughout the project.

This updates the project settings, and renames any files associated with the behavior.
It also updates any labels for this behavior in all videos.

Args:
old_name (str): current behavior name
new_name (str): new behavior name

Returns:
None
"""
if new_name in self._settings_manager.behavior_names:
raise ValueError(f"Behavior {new_name} already exists in project")

safe_old_name = to_safe_name(old_name)
safe_new_name = to_safe_name(new_name)

# rename pickled classifier
old_path = self._paths.classifier_dir / f"{safe_old_name}.pickle"
new_path = self._paths.classifier_dir / f"{safe_new_name}.pickle"
if old_path.exists():
old_path.rename(new_path)

for video in self._video_manager.videos:
# Rename predictions dataset inside the per-video HDF5 file.
pred_file = self._paths.prediction_dir / Path(video).with_suffix(".h5").name
if pred_file.exists():
with h5py.File(pred_file, "r+") as hf:
if "predictions" in hf and safe_old_name in hf["predictions"]:
grp = hf["predictions"]
# If dataset already exists at the destination, remove it first
if safe_new_name in grp:
del grp[safe_new_name]
grp.move(safe_old_name, safe_new_name)

# rename labels inside annotation file
if (labels := self._video_manager.load_video_labels(video)) is None:
continue
labels.rename_behavior(old_name, new_name)
pose = self.load_pose_est(self._video_manager.video_path(video))
self.save_annotations(labels, pose)

# update project settings
self._settings_manager.rename_behavior(old_name, new_name)
25 changes: 25 additions & 0 deletions src/jabs/project/settings_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,3 +180,28 @@ def update_version(self):
current_version = self._project_info.get("version")
if current_version != version_str():
self.save_project_file({"version": version_str()})

def rename_behavior(self, old_name: str, new_name: str) -> None:
"""Rename a behavior in the project settings.

Args:
old_name: Current name of the behavior to rename.
new_name: New name for the behavior.

Raises:
KeyError: If the old behavior name does not exist or
the new behavior name already exists.
"""
if old_name not in self._project_info.get("behavior", {}):
raise KeyError(f"Behavior '{old_name}' not found in project.")

if new_name in self._project_info.get("behavior", {}):
raise KeyError(f"Behavior '{new_name}' already exists in project.")

self._project_info["behavior"][new_name] = self._project_info["behavior"].pop(old_name)

# if the old behavior was the selected behavior, update to new name
if self._project_info.get("selected_behavior") == old_name:
self._project_info["selected_behavior"] = new_name

self.save_project_file()
28 changes: 28 additions & 0 deletions src/jabs/project/video_labels.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,3 +242,31 @@ def merge(self, other: "VideoLabels", strategy: "MergeStrategy") -> None:
for behavior, other_track_labels in behaviors.items():
track_labels = self.get_track_labels(identity, behavior)
track_labels.merge(other_track_labels, strategy)

def rename_behavior(self, old_name: str, new_name: str) -> None:
"""Rename a behavior across all identities in this VideoLabels object.

Only renames behavior in memory; does not update any associated files on disk.
Behavior labels must be saved to disk again after renaming to persist changes.

Args:
old_name (str): The current name of the behavior to rename.
new_name (str): The new name for the behavior.

Returns:
None

Raises:
KeyError: If the old behavior name does not exist or
if the new behavior name already exists for any identity.
"""
# validate that new_name doesn't already exist for any identity
for identity in self._identity_labels:
if new_name in self._identity_labels[identity]:
raise KeyError(f"Behavior '{new_name}' already exists for identity '{identity}'")

for identity in self._identity_labels:
if old_name in self._identity_labels[identity]:
self._identity_labels[identity][new_name] = self._identity_labels[identity].pop(
old_name
)
56 changes: 56 additions & 0 deletions src/jabs/scripts/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,62 @@ def export_training(ctx, directory: Path, behavior: str, classifier: str, outfil
click.echo(f"Exported training data to {outfile}")


@cli.command(name="rename-behavior")
@click.argument(
"directory",
type=click.Path(
exists=True,
file_okay=False,
dir_okay=True,
path_type=Path,
),
)
@click.argument(
"old-name",
type=str,
)
@click.argument(
"new-name",
type=str,
)
@click.pass_context
def rename_behavior(ctx, directory: Path, old_name: str, new_name: str) -> None:
"""Rename a behavior in a JABS project."""
# Args:
# ctx: Click context.
# directory (Path): Path to the JABS project directory.
# old_name (str): Current name of the behavior to rename.
# new_name (str): New name for the behavior.
#
# Raises:
# click.ClickException: If the old behavior name does not exist or
# the new behavior name already exists.

if ctx.obj["VERBOSE"]:
click.echo("Renaming behavior with the following parameters:")
click.echo(f"\tOld behavior name: {old_name}")
click.echo(f"\tNew behavior name: {new_name}")
click.echo(f"\tJABS project directory: {directory}")

if not Project.is_valid_project_directory(directory):
raise click.ClickException(f"Invalid JABS project directory: {directory}")

jabs_project = Project(directory, enable_session_tracker=False)

# validate that the old behavior exists in the project
if old_name not in jabs_project.settings["behavior"]:
raise click.ClickException(f"Behavior '{old_name}' not found in project.")

# validate that the new behavior does not already exist in the project
if new_name in jabs_project.settings["behavior"]:
raise click.ClickException(f"Behavior '{new_name}' already exists in project.")

console = Console()
status_text = f"Renaming behavior '{old_name}' to '{new_name}'"
with console.status(status_text, spinner="dots"):
jabs_project.rename_behavior(old_name, new_name)


def main():
"""Entry point for the JABS CLI."""
cli(obj={})
Expand Down
Loading