From e97d2d9d749892a5dcafb99bb0ef943ba843e143 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Fri, 7 Mar 2025 17:10:02 -0500 Subject: [PATCH 01/27] adding pose processors and base pose metric that uses them --- pose_evaluation/metrics/base_pose_metric.py | 40 ++++- pose_evaluation/metrics/pose_processors.py | 160 ++++++++++++++++++++ 2 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 pose_evaluation/metrics/pose_processors.py diff --git a/pose_evaluation/metrics/base_pose_metric.py b/pose_evaluation/metrics/base_pose_metric.py index 903bf01..1f01033 100644 --- a/pose_evaluation/metrics/base_pose_metric.py +++ b/pose_evaluation/metrics/base_pose_metric.py @@ -1,5 +1,41 @@ +from typing import Iterable, List, cast, Union from pose_format import Pose -from pose_evaluation.metrics.base import BaseMetric +from pose_evaluation.metrics.base import BaseMetric, Signature, Score +from pose_evaluation.metrics.pose_processors import PoseProcessor -PoseMetric = BaseMetric[Pose] +class PoseMetricSignature(Signature): + pass + +class PoseMetricScore(Score): + pass + +class PoseMetric(BaseMetric[Pose]): + _SIGNATURE_TYPE = PoseMetricSignature + + def __init__( + self, + name: str = "PoseMetric", + higher_is_better: bool = False, + pose_preprocessors: Union[None, List[PoseProcessor]] = None, + ): + + super().__init__(name, higher_is_better) + if pose_preprocessors is None: + self.pose_preprocessors = [] + else: + self.pose_preprocessors = pose_preprocessors + + def score(self, hypothesis: Pose, reference: Pose) -> float: + hypothesis, reference = self.process_poses([hypothesis, reference]) + return self.score(hypothesis, reference) + + def process_poses(self, poses: Iterable[Pose]) -> List[Pose]: + poses = list(poses) + for preprocessor in self.pose_preprocessors: + preprocessor = cast(PoseProcessor, preprocessor) + poses = preprocessor.process_poses(poses) + return poses + + def add_preprocessor(self, processor: PoseProcessor): + self.pose_preprocessors.append(processor) diff --git a/pose_evaluation/metrics/pose_processors.py b/pose_evaluation/metrics/pose_processors.py new file mode 100644 index 0000000..90d58e4 --- /dev/null +++ b/pose_evaluation/metrics/pose_processors.py @@ -0,0 +1,160 @@ +from typing import Any, List, Union, Iterable, Callable +from pose_format import Pose + +from pose_evaluation.metrics.base import SignatureMixin +from pose_evaluation.utils.pose_utils import ( + remove_components, + pose_remove_legs, + get_face_and_hands_from_pose, + reduce_pose_components_and_points_to_intersection, + zero_pad_shorter_poses, + copy_pose, + pose_hide_low_conf, + set_masked_to_origin_position, +) + +PosesTransformerFunctionType = Callable[[Iterable[Pose]], List[Pose]] + + +class PoseProcessor(SignatureMixin): + def __init__(self, name="PoseProcessor") -> None: + self.name = name + + def __call__(self, pose_or_poses: Union[Iterable[Pose], Pose]) -> Any: + if isinstance(pose_or_poses, Iterable): + return self.process_poses(pose_or_poses) + else: + return self.process_pose(pose_or_poses) + + def __repr__(self) -> str: + return self.name + + def __str__(self) -> str: + return self.name + + def process_pose(self, pose: Pose) -> Pose: + return pose + + def process_poses(self, poses: Iterable[Pose]) -> List[Pose]: + return [self.process_pose(pose) for pose in poses] + + +class RemoveComponentsProcessor(PoseProcessor): + def __init__(self, landmarks: List[str]) -> None: + super().__init__(f"remove_landmarks[landmarks{landmarks}]") + self.landmarks = landmarks + + def process_pose(self, pose: Pose) -> Pose: + return remove_components(pose, self.landmarks) + + +class RemoveWorldLandmarksProcessor(RemoveComponentsProcessor): + def __init__(self) -> None: + landmarks = ["POSE_WORLD_LANDMARKS"] + super().__init__(landmarks) + + +class RemoveLegsPosesProcessor(PoseProcessor): + def __init__(self, name="remove_legs") -> None: + super().__init__(name) + + def process_pose(self, pose: Pose) -> Pose: + return pose_remove_legs(pose) + + +class GetFaceAndHandsProcessor(PoseProcessor): + def __init__(self, name="face_and_hands") -> None: + super().__init__(name) + + def process_pose(self, pose: Pose) -> Pose: + return get_face_and_hands_from_pose(pose) + + +class ReducePosesToCommonComponentsProcessor(PoseProcessor): + def __init__(self, name="reduce_pose_components") -> None: + super().__init__(name) + + def process_pose(self, pose: Pose) -> Pose: + return self.process_poses([pose])[0] + + def process_poses(self, poses: Iterable[Pose]) -> List[Pose]: + return reduce_pose_components_and_points_to_intersection(poses) + + +class ZeroPadShorterPosesProcessor(PoseProcessor): + def __init__(self) -> None: + super().__init__(name="zero_pad_shorter_sequence") + + def process_poses(self, poses: Iterable[Pose]) -> List[Pose]: + return zero_pad_shorter_poses(poses) + + +class PadOrTruncateByReferencePosesProcessor(PoseProcessor): + def __init__(self) -> None: + super().__init__(name="by_reference") + + def process_poses(self, poses: Iterable[Pose]) -> List[Pose]: + raise NotImplementedError # TODO + + +class NormalizePosesProcessor(PoseProcessor): + def __init__(self, info=None, scale_factor=1) -> None: + super().__init__(f"normalize_poses[info:{info},scale_factor:{scale_factor}]") + self.info = info + self.scale_factor = scale_factor + + def process_pose(self, pose: Pose) -> Pose: + return pose.normalize(self.info, self.scale_factor) + + +class HideLowConfProcessor(PoseProcessor): + def __init__(self, conf_threshold: float = 0.2) -> None: + + super().__init__(f"hide_low_conf[{conf_threshold}]") + self.conf_threshold = conf_threshold + + def process_pose(self, pose: Pose) -> Pose: + pose = copy_pose(pose) + pose_hide_low_conf(pose, self.conf_threshold) + return pose + + +class SetMaskedValuesToOriginPositionProcessor(PoseProcessor): + def __init__( + self, + ) -> None: + super().__init__(name="set_masked_to_origin") + + def process_pose(self, pose: Pose) -> Pose: + return set_masked_to_origin_position(pose) + + +def get_standard_pose_processors( + normalize_poses: bool = True, + reduce_poses_to_common_components: bool = True, + remove_world_landmarks=True, + remove_legs=True, + zero_pad_shorter=True, + set_masked_values_to_origin=False, +) -> List[PoseProcessor]: + pose_processors = [] + + if normalize_poses: + pose_processors.append(NormalizePosesProcessor()) + + if reduce_poses_to_common_components: + pose_processors.append(ReducePosesToCommonComponentsProcessor()) + + if remove_world_landmarks: + pose_processors.append(RemoveWorldLandmarksProcessor()) + + if remove_legs: + pose_processors.append(RemoveLegsPosesProcessor()) + + if zero_pad_shorter: + pose_processors.append(ZeroPadShorterPosesProcessor()) + + if set_masked_values_to_origin: + pose_processors.append(SetMaskedValuesToOriginPositionProcessor()) + + return pose_processors \ No newline at end of file From ee74ae748e5a701fee433ddab3656616aba5c914 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 10 Mar 2025 15:57:18 -0400 Subject: [PATCH 02/27] Taking a pass at DTWMetric, and pose_processing --- .../examples/example_metric_construction.py | 68 +++++++--- pose_evaluation/metrics/base.py | 26 +++- pose_evaluation/metrics/base_pose_metric.py | 54 ++++++-- pose_evaluation/metrics/distance_measure.py | 11 ++ pose_evaluation/metrics/distance_metric.py | 7 +- pose_evaluation/metrics/dtw_metric.py | 35 +++++ pose_evaluation/metrics/pose_processors.py | 126 +++++++----------- pose_evaluation/utils/pose_utils.py | 6 +- 8 files changed, 218 insertions(+), 115 deletions(-) create mode 100644 pose_evaluation/metrics/dtw_metric.py diff --git a/pose_evaluation/examples/example_metric_construction.py b/pose_evaluation/examples/example_metric_construction.py index c1ff2db..66d3646 100644 --- a/pose_evaluation/examples/example_metric_construction.py +++ b/pose_evaluation/examples/example_metric_construction.py @@ -3,8 +3,11 @@ from pose_evaluation.metrics.distance_metric import DistanceMetric from pose_evaluation.metrics.distance_measure import AggregatedPowerDistance from pose_evaluation.metrics.base import BaseMetric +from pose_evaluation.metrics.base_pose_metric import PoseMetric +from pose_evaluation.metrics.dtw_metric import DTWAggregatedPowerDistance from pose_evaluation.metrics.test_distance_metric import get_poses from pose_evaluation.utils.pose_utils import zero_pad_shorter_poses +from pose_evaluation.metrics.pose_processors import ZeroPadShorterPosesProcessor, get_standard_pose_processors if __name__ == "__main__": # Define file paths for test pose data @@ -26,52 +29,75 @@ Pose.read(reference_file.read_bytes()), ] # TODO: add PosePreprocessors to PoseDistanceMetrics, with their own signatures - poses = zero_pad_shorter_poses(poses) + # poses = zero_pad_shorter_poses(poses) else: hypothesis, reference = get_poses(2, 2, conf1=1, conf2=1) poses = [hypothesis, reference] + hypotheses = [pose.copy() for pose in poses] + references = [pose.copy() for pose in poses] + # Define distance metrics mean_l1_metric = DistanceMetric( "mean_l1_metric", distance_measure=AggregatedPowerDistance(1, 17) ) metrics = [ - BaseMetric("base"), - DistanceMetric("PowerDistanceMetric", AggregatedPowerDistance(2, 1)), - DistanceMetric("AnotherPowerDistanceMetric", AggregatedPowerDistance(1, 10)), - mean_l1_metric, - DistanceMetric( - "max_l1_metric", - AggregatedPowerDistance( - order=1, aggregation_strategy="max", default_distance=0 - ), - ), + # BaseMetric("base"), + # DistanceMetric("PowerDistanceMetric", AggregatedPowerDistance(2, 1)), + # DistanceMetric("AnotherPowerDistanceMetric", AggregatedPowerDistance(1, 10)), + # mean_l1_metric, + # DistanceMetric( + # "max_l1_metric", + # AggregatedPowerDistance( + # order=1, aggregation_strategy="max", default_distance=0 + # ), + # ), + # DistanceMetric( + # "MeanL2Score", + # AggregatedPowerDistance( + # order=2, aggregation_strategy="mean", default_distance=0 + # ), + # pose_preprocessors=[ZeroPadShorterPosesProcessor()] + # ), + # DistanceMetric( + # "MeanL2Score", + # AggregatedPowerDistance( + # order=2, aggregation_strategy="mean", default_distance=0 + # ), + # ), + # PoseMetric() DistanceMetric( - "MeanL2Score", - AggregatedPowerDistance( - order=2, aggregation_strategy="mean", default_distance=0 + "DTWPowerDistance", + DTWAggregatedPowerDistance( + order=2, aggregation_strategy="mean", default_distance=0.0 ), + pose_preprocessors=get_standard_pose_processors(zero_pad_shorter=False) ), ] # Evaluate each metric on the test poses for metric in metrics: print("*" * 10) + print(metric.name) + print("\nSIGNATURE: ") print(metric.get_signature().format()) + + print("\nSIGNATURE (short): ") print(metric.get_signature().format(short=True)) try: + print(f"\nSCORE ALL with Signature (short):") + print(metric.score_all_with_signature(hypotheses, references, short=True, progress_bar=True)) score = metric.score(poses[0], poses[1]) - print(f"SCORE: {score}") - print("SCORE With Signature:") - score_with_sig = metric.score_with_signature(poses[0], poses[1]) - print(score_with_sig) - print(repr(score_with_sig)) - print(f"{type(score_with_sig)}") - + print(f"\nSCORE: {score}") + print("\nSCORE With Signature:") + print(metric.score_with_signature(poses[0], poses[1])) + print(f"\nSCORE with Signature (short):") print(metric.score_with_signature(poses[0], poses[1], short=True)) + + except NotImplementedError: print(f"{metric} score not implemented") print("*" * 10) diff --git a/pose_evaluation/metrics/base.py b/pose_evaluation/metrics/base.py index 1c69942..2f33487 100644 --- a/pose_evaluation/metrics/base.py +++ b/pose_evaluation/metrics/base.py @@ -20,7 +20,6 @@ def update_abbr(self, key: str, abbr: str): def update_signature_and_abbr(self, key: str, abbr: str, args: dict): self.update_abbr(key, abbr) - self.signature_info.update({key: args.get(key, None)}) def format(self, short: bool = False) -> str: @@ -38,6 +37,15 @@ def format(self, short: bool = False) -> str: nested_signature = value.get_signature() if isinstance(nested_signature, Signature): value = "{" + nested_signature.format(short=short) + "}" + elif isinstance(value, list) and all( + hasattr(v, "get_signature") for v in value + ): + value = ( + "[" + + ",".join(v.get_signature().format(short=short) for v in value) + + "]" + ) + if isinstance(value, bool): value = "yes" if value else "no" if isinstance(value, Callable): @@ -117,7 +125,7 @@ def corpus_score( return sum(scores) / len(hypotheses) def score_all( - self, hypotheses: Sequence[T], references: Sequence[T], progress_bar=True + self, hypotheses: Sequence[T], references: Sequence[T], progress_bar=False ) -> list[list[float]]: """Call the score function for each hypothesis-reference pair.""" return [ @@ -125,8 +133,20 @@ def score_all( for h in tqdm(hypotheses, disable=not progress_bar or len(hypotheses) == 1) ] + def score_all_with_signature( + self, + hypotheses: Sequence[T], + references: Sequence[T], + progress_bar=False, + short: bool = False, + ) -> list[list[Score]]: + return [ + [self.score_with_signature(h, r, short=short) for r in references] + for h in tqdm(hypotheses, disable=not progress_bar or len(hypotheses) == 1) + ] + def __str__(self): - return self.name + return f"{self.get_signature()}" def get_signature(self) -> Signature: return self._SIGNATURE_TYPE(self.name, self.__dict__) diff --git a/pose_evaluation/metrics/base_pose_metric.py b/pose_evaluation/metrics/base_pose_metric.py index 1f01033..ba900b4 100644 --- a/pose_evaluation/metrics/base_pose_metric.py +++ b/pose_evaluation/metrics/base_pose_metric.py @@ -1,15 +1,24 @@ -from typing import Iterable, List, cast, Union +from typing import Iterable, List, Sequence, cast, Union +from tqdm import tqdm + from pose_format import Pose from pose_evaluation.metrics.base import BaseMetric, Signature, Score from pose_evaluation.metrics.pose_processors import PoseProcessor +from pose_evaluation.metrics.pose_processors import get_standard_pose_processors + class PoseMetricSignature(Signature): - pass + def __init__(self, name: str, args: dict): + super().__init__(name, args) + self.update_abbr("pose_preprocessors", "pre") + # self.update_signature_and_abbr("pose_preprocessors", "pre", args) + class PoseMetricScore(Score): pass + class PoseMetric(BaseMetric[Pose]): _SIGNATURE_TYPE = PoseMetricSignature @@ -22,19 +31,48 @@ def __init__( super().__init__(name, higher_is_better) if pose_preprocessors is None: - self.pose_preprocessors = [] + self.pose_preprocessors = get_standard_pose_processors() else: self.pose_preprocessors = pose_preprocessors - def score(self, hypothesis: Pose, reference: Pose) -> float: + def score_with_signature( + self, hypothesis: Pose, reference: Pose, short: bool = False + ) -> PoseMetricScore: hypothesis, reference = self.process_poses([hypothesis, reference]) - return self.score(hypothesis, reference) + return PoseMetricScore( + name=self.name, + score=self.score(hypothesis, reference), + signature=self.get_signature().format(short=short), + ) + + def score_all_with_signature( + self, + hypotheses: Sequence[Pose], + references: Sequence[Pose], + progress_bar=False, + short: bool = False, + ) -> list[list[Score]]: + hyp_len = len(hypotheses) + ref_len = len(references) + + all_poses = self.process_poses(hypotheses + references) + + # Recover original lists if needed + hypotheses = all_poses[:hyp_len] + references = all_poses[hyp_len : hyp_len + ref_len] + + return [ + [self.score_with_signature(h, r , short=short) for r in references] + for h in tqdm(hypotheses, desc="scoring:", disable=not progress_bar or len(hypotheses) == 1) + ] - def process_poses(self, poses: Iterable[Pose]) -> List[Pose]: + def process_poses(self, poses: Iterable[Pose], progress=False) -> List[Pose]: poses = list(poses) - for preprocessor in self.pose_preprocessors: + for preprocessor in tqdm( + self.pose_preprocessors, desc="Preprocessing Poses", disable=not progress + ): preprocessor = cast(PoseProcessor, preprocessor) - poses = preprocessor.process_poses(poses) + poses = preprocessor.process_poses(poses, progress=progress) return poses def add_preprocessor(self, processor: PoseProcessor): diff --git a/pose_evaluation/metrics/distance_measure.py b/pose_evaluation/metrics/distance_measure.py index 1ab5433..75c7671 100644 --- a/pose_evaluation/metrics/distance_measure.py +++ b/pose_evaluation/metrics/distance_measure.py @@ -26,9 +26,20 @@ def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> fl This method should be implemented by subclasses. """ raise NotImplementedError + + def _get_keypoint_trajectories(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray): + # frames, persons, keypoint + for keypoint_idx in range(hyp_data.shape[2]): + hyp_trajectory, ref_trajectory =hyp_data[:, 0, keypoint_idx, :], ref_data[:, 0, keypoint_idx, :] + yield hyp_trajectory, ref_trajectory def __call__(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> float: return self.get_distance(hyp_data, ref_data) + + def _calculate_distances( + self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray + ) -> ma.MaskedArray: + raise NotImplementedError def get_signature(self) -> Signature: """Return the signature of the distance measure.""" diff --git a/pose_evaluation/metrics/distance_metric.py b/pose_evaluation/metrics/distance_metric.py index 830db13..0c06a10 100644 --- a/pose_evaluation/metrics/distance_metric.py +++ b/pose_evaluation/metrics/distance_metric.py @@ -1,15 +1,18 @@ +from typing import List from pose_format import Pose from pose_evaluation.metrics.base_pose_metric import PoseMetric from pose_evaluation.metrics.distance_measure import DistanceMeasure +from pose_evaluation.metrics.pose_processors import PoseProcessor class DistanceMetric(PoseMetric): """Computes the distance between two poses using the provided distance measure.""" - def __init__(self, name: str, distance_measure: DistanceMeasure) -> None: - super().__init__(name=name, higher_is_better=False) + def __init__(self, name: str, distance_measure: DistanceMeasure, pose_preprocessors: List[PoseProcessor] | None = None) -> None: + super().__init__(name=name, higher_is_better=False, pose_preprocessors=pose_preprocessors) self.distance_measure = distance_measure def score(self, hypothesis: Pose, reference: Pose) -> float: """Calculate the distance score between hypothesis and reference poses.""" + hypothesis, reference = self.process_poses([hypothesis, reference]) return self.distance_measure(hypothesis.body.data, reference.body.data) diff --git a/pose_evaluation/metrics/dtw_metric.py b/pose_evaluation/metrics/dtw_metric.py new file mode 100644 index 0000000..91aaff3 --- /dev/null +++ b/pose_evaluation/metrics/dtw_metric.py @@ -0,0 +1,35 @@ +import numpy as np +from fastdtw import fastdtw +import numpy.ma as ma +from tqdm import tqdm + +from pose_evaluation.metrics.distance_measure import ( + AggregatedPowerDistance, + AggregationStrategy, +) + + +class DTWAggregatedPowerDistance(AggregatedPowerDistance): + def __init__( + self, + order: int = 2, + default_distance: float = 0, + aggregation_strategy: AggregationStrategy = "mean", + ) -> None: + super().__init__(order, default_distance, aggregation_strategy) + + def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> float: + keypoint_count = hyp_data.shape[ + 2 + ] # Assuming shape: (frames, person, keypoints, xyz) + traj_distances = np.empty(keypoint_count) # Preallocate a NumPy array + + for i, (hyp_traj, ref_traj) in tqdm( + enumerate(self._get_keypoint_trajectories(hyp_data, ref_data)), + desc="getting dtw distances for trajectories", + total=keypoint_count, + ): + distance, _ = fastdtw(hyp_traj, ref_traj, dist=self._calculate_distances) + traj_distances[i] = distance # Store distance in the preallocated array + + return self._aggregate(traj_distances) diff --git a/pose_evaluation/metrics/pose_processors.py b/pose_evaluation/metrics/pose_processors.py index 90d58e4..94c91cb 100644 --- a/pose_evaluation/metrics/pose_processors.py +++ b/pose_evaluation/metrics/pose_processors.py @@ -1,22 +1,26 @@ from typing import Any, List, Union, Iterable, Callable -from pose_format import Pose -from pose_evaluation.metrics.base import SignatureMixin +from tqdm import tqdm + +from pose_format import Pose +from pose_format.utils.generic import pose_hide_legs +from pose_evaluation.metrics.base import Signature from pose_evaluation.utils.pose_utils import ( - remove_components, - pose_remove_legs, get_face_and_hands_from_pose, - reduce_pose_components_and_points_to_intersection, zero_pad_shorter_poses, - copy_pose, - pose_hide_low_conf, - set_masked_to_origin_position, + reduce_poses_to_intersection, ) PosesTransformerFunctionType = Callable[[Iterable[Pose]], List[Pose]] -class PoseProcessor(SignatureMixin): +class PoseProcessorSignature(Signature): + pass + + +class PoseProcessor: + _SIGNATURE_TYPE = Signature + def __init__(self, name="PoseProcessor") -> None: self.name = name @@ -27,115 +31,82 @@ def __call__(self, pose_or_poses: Union[Iterable[Pose], Pose]) -> Any: return self.process_pose(pose_or_poses) def __repr__(self) -> str: - return self.name + return f"{self.get_signature()}" def __str__(self) -> str: - return self.name + return f"{self.get_signature()}" def process_pose(self, pose: Pose) -> Pose: return pose - def process_poses(self, poses: Iterable[Pose]) -> List[Pose]: - return [self.process_pose(pose) for pose in poses] + def process_poses(self, poses: Iterable[Pose], progress=False) -> List[Pose]: + return [self.process_pose(pose) for pose in tqdm(poses, desc=f"{self.name}", disable=not progress)] + def get_signature(self) -> Signature: + return self._SIGNATURE_TYPE(self.name, self.__dict__) -class RemoveComponentsProcessor(PoseProcessor): - def __init__(self, landmarks: List[str]) -> None: - super().__init__(f"remove_landmarks[landmarks{landmarks}]") - self.landmarks = landmarks - def process_pose(self, pose: Pose) -> Pose: - return remove_components(pose, self.landmarks) +class NormalizePosesSignature(Signature): + def __init__(self, name: str, args: dict): + super().__init__(name, args) + self.update_signature_and_abbr("scale_factor", "s", args) + self.update_signature_and_abbr("info", "i", args) -class RemoveWorldLandmarksProcessor(RemoveComponentsProcessor): - def __init__(self) -> None: - landmarks = ["POSE_WORLD_LANDMARKS"] - super().__init__(landmarks) +class NormalizePosesProcessor(PoseProcessor): + _SIGNATURE_TYPE = NormalizePosesSignature + + def __init__(self, info=None, scale_factor=1) -> None: + super().__init__("normalize_poses") + self.info = info + self.scale_factor = scale_factor + + def process_pose(self, pose: Pose) -> Pose: + return pose.normalize(self.info, self.scale_factor) -class RemoveLegsPosesProcessor(PoseProcessor): - def __init__(self, name="remove_legs") -> None: +class RemoveWorldLandmarksProcessor(PoseProcessor): + def __init__(self, name="remove_world_landmarks") -> None: super().__init__(name) def process_pose(self, pose: Pose) -> Pose: - return pose_remove_legs(pose) + return pose.remove_components(["WORLD_LANDMARKS"]) -class GetFaceAndHandsProcessor(PoseProcessor): - def __init__(self, name="face_and_hands") -> None: +class HideLegsPosesProcessor(PoseProcessor): + def __init__(self, name="hide_legs", remove=True) -> None: super().__init__(name) + self.remove = remove def process_pose(self, pose: Pose) -> Pose: - return get_face_and_hands_from_pose(pose) + return pose_hide_legs(pose, remove=self.remove) class ReducePosesToCommonComponentsProcessor(PoseProcessor): - def __init__(self, name="reduce_pose_components") -> None: + def __init__(self, name="reduce_poses_to_intersection") -> None: super().__init__(name) def process_pose(self, pose: Pose) -> Pose: return self.process_poses([pose])[0] - def process_poses(self, poses: Iterable[Pose]) -> List[Pose]: - return reduce_pose_components_and_points_to_intersection(poses) + def process_poses(self, poses: Iterable[Pose], progress=False) -> List[Pose]: + return reduce_poses_to_intersection(poses, progress=progress) class ZeroPadShorterPosesProcessor(PoseProcessor): def __init__(self) -> None: super().__init__(name="zero_pad_shorter_sequence") - def process_poses(self, poses: Iterable[Pose]) -> List[Pose]: + def process_poses(self, poses: Iterable[Pose], progress=False) -> List[Pose]: return zero_pad_shorter_poses(poses) -class PadOrTruncateByReferencePosesProcessor(PoseProcessor): - def __init__(self) -> None: - super().__init__(name="by_reference") - - def process_poses(self, poses: Iterable[Pose]) -> List[Pose]: - raise NotImplementedError # TODO - - -class NormalizePosesProcessor(PoseProcessor): - def __init__(self, info=None, scale_factor=1) -> None: - super().__init__(f"normalize_poses[info:{info},scale_factor:{scale_factor}]") - self.info = info - self.scale_factor = scale_factor - - def process_pose(self, pose: Pose) -> Pose: - return pose.normalize(self.info, self.scale_factor) - - -class HideLowConfProcessor(PoseProcessor): - def __init__(self, conf_threshold: float = 0.2) -> None: - - super().__init__(f"hide_low_conf[{conf_threshold}]") - self.conf_threshold = conf_threshold - - def process_pose(self, pose: Pose) -> Pose: - pose = copy_pose(pose) - pose_hide_low_conf(pose, self.conf_threshold) - return pose - - -class SetMaskedValuesToOriginPositionProcessor(PoseProcessor): - def __init__( - self, - ) -> None: - super().__init__(name="set_masked_to_origin") - - def process_pose(self, pose: Pose) -> Pose: - return set_masked_to_origin_position(pose) - - def get_standard_pose_processors( normalize_poses: bool = True, reduce_poses_to_common_components: bool = True, remove_world_landmarks=True, remove_legs=True, zero_pad_shorter=True, - set_masked_values_to_origin=False, ) -> List[PoseProcessor]: pose_processors = [] @@ -149,12 +120,9 @@ def get_standard_pose_processors( pose_processors.append(RemoveWorldLandmarksProcessor()) if remove_legs: - pose_processors.append(RemoveLegsPosesProcessor()) + pose_processors.append(HideLegsPosesProcessor()) if zero_pad_shorter: pose_processors.append(ZeroPadShorterPosesProcessor()) - if set_masked_values_to_origin: - pose_processors.append(SetMaskedValuesToOriginPositionProcessor()) - - return pose_processors \ No newline at end of file + return pose_processors diff --git a/pose_evaluation/utils/pose_utils.py b/pose_evaluation/utils/pose_utils.py index cfc8147..3b4b112 100644 --- a/pose_evaluation/utils/pose_utils.py +++ b/pose_evaluation/utils/pose_utils.py @@ -4,6 +4,7 @@ import numpy as np from numpy import ma from pose_format import Pose +from tqdm import tqdm def pose_remove_world_landmarks(pose: Pose) -> Pose: @@ -43,15 +44,16 @@ def load_pose_file(pose_path: Path) -> Pose: def reduce_poses_to_intersection( poses: Iterable[Pose], + progress=False, ) -> List[Pose]: - poses = list(poses) # get a list, no need to copy + poses = list(poses) # get a list, no need to copy # look at the first pose component_names = {c.name for c in poses[0].header.components} points = {c.name: set(c.points) for c in poses[0].header.components} # remove anything that other poses don't have - for pose in poses[1:]: + for pose in tqdm(poses[1:], desc="reduce poses to intersection", disable=not progress): component_names.intersection_update({c.name for c in pose.header.components}) for component in pose.header.components: points[component.name].intersection_update(set(component.points)) From bc472246fae56e5ed7f81b698241e6996d5e694d Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Fri, 14 Mar 2025 16:06:04 -0400 Subject: [PATCH 03/27] updated get_poses function to give more control of shape. --- pose_evaluation/metrics/test_distance_metric.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/pose_evaluation/metrics/test_distance_metric.py b/pose_evaluation/metrics/test_distance_metric.py index 7d2278a..65cd6b4 100644 --- a/pose_evaluation/metrics/test_distance_metric.py +++ b/pose_evaluation/metrics/test_distance_metric.py @@ -14,6 +14,14 @@ def get_poses( length2: int, conf1: Optional[float] = None, conf2: Optional[float] = None, + people1: int = 3, + people2: int = 3, + keypoints1: int = 4, + keypoints2: int = 4, + coordinate_dimensions1: int = 3, + coordinate_dimensions2: int = 3, + fill_value1: float = 1.0, + fill_value2: float = 0.0, ): """ Utility function to generate hypothesis and reference Pose objects for testing. @@ -27,8 +35,13 @@ def get_poses( Returns: tuple: A tuple containing (hypothesis, reference) Pose objects. """ - data_tensor = np.full((length1, 3, 4, 3), fill_value=2) - zeros_tensor = np.zeros((length2, 3, 4, 3)) + + data_tensor = np.full( + [length1, people1, keypoints1, coordinate_dimensions1], fill_value=fill_value1 + ) + zeros_tensor = np.full( + (length2, people2, keypoints2, coordinate_dimensions2), fill_value=fill_value2 + ) data_confidence = np.ones(data_tensor.shape[:-1]) zeros_confidence = np.ones(zeros_tensor.shape[:-1]) From ac761e08328508e02db5b40e3a50482aa7677b5e Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Fri, 14 Mar 2025 16:57:11 -0400 Subject: [PATCH 04/27] DTW now inherits better. Also various fixes and ran black on the whole dir --- .../evaluation/evaluate_signclip.py | 83 +++++++--- .../examples/example_metric_construction.py | 102 ++++++++----- pose_evaluation/metrics/base.py | 29 +++- pose_evaluation/metrics/base_pose_metric.py | 40 +++-- pose_evaluation/metrics/conftest.py | 41 +++-- pose_evaluation/metrics/distance_measure.py | 87 +++++++---- pose_evaluation/metrics/distance_metric.py | 11 +- pose_evaluation/metrics/dtw_metric.py | 78 +++++++++- .../metrics/embedding_distance_metric.py | 36 +++-- pose_evaluation/metrics/pose_processors.py | 41 ++++- pose_evaluation/metrics/segmented_metric.py | 16 +- pose_evaluation/metrics/test_dtw_metric.py | 47 ++++++ .../metrics/test_embedding_distance_metric.py | 142 ++++++++++++++---- pose_evaluation/utils/conftest.py | 1 - pose_evaluation/utils/pose_utils.py | 4 +- pose_evaluation/utils/test_pose_utils.py | 47 ++++-- 16 files changed, 622 insertions(+), 183 deletions(-) create mode 100644 pose_evaluation/metrics/test_dtw_metric.py diff --git a/pose_evaluation/evaluation/evaluate_signclip.py b/pose_evaluation/evaluation/evaluate_signclip.py index d293c05..851802d 100644 --- a/pose_evaluation/evaluation/evaluate_signclip.py +++ b/pose_evaluation/evaluation/evaluate_signclip.py @@ -9,6 +9,7 @@ from tqdm import tqdm from pose_evaluation.metrics.embedding_distance_metric import EmbeddingDistanceMetric + def load_embedding(file_path: Path) -> np.ndarray: """ Load a SignCLIP embedding from a .npy file, ensuring it has the correct shape. @@ -39,7 +40,9 @@ def match_embeddings_to_glosses(emb_dir: Path, split_df: pd.DataFrame) -> pd.Dat # Step 1: Create a mapping of numerical IDs to .npy files map_start = time.perf_counter() - embeddings_map = {npy_file.stem.split("-")[0]: npy_file for npy_file in emb_dir.glob("*.npy")} + embeddings_map = { + npy_file.stem.split("-")[0]: npy_file for npy_file in emb_dir.glob("*.npy") + } map_end = time.perf_counter() print(f"Creating embeddings map took {map_end - map_start:.4f} seconds") @@ -61,7 +64,10 @@ def get_embedding(video_file): def calculate_mean_distances( - distance_matrix: torch.Tensor, indices_a: torch.Tensor, indices_b: torch.Tensor, exclude_self: bool = False + distance_matrix: torch.Tensor, + indices_a: torch.Tensor, + indices_b: torch.Tensor, + exclude_self: bool = False, ) -> float: """ Calculate the mean of distances between two sets of indices in a 2D distance matrix. @@ -102,7 +108,8 @@ def generate_synthetic_data(num_items, num_classes, num_items_per_class=4): random.shuffle(indices) classes = { - f"CLASS_{i}": torch.tensor([indices.pop() for _ in range(num_items_per_class)]) for i in range(num_classes) + f"CLASS_{i}": torch.tensor([indices.pop() for _ in range(num_items_per_class)]) + for i in range(num_classes) } # Assign intra-class distances mean_values_by_class = {} @@ -122,15 +129,21 @@ def calculate_class_means(gloss_indices, scores): class_means_by_gloss = {} all_indices = torch.arange(scores.size(0), dtype=int) - for gloss, indices in tqdm(gloss_indices.items(), desc="Finding mean values by gloss"): + for gloss, indices in tqdm( + gloss_indices.items(), desc="Finding mean values by gloss" + ): indices = torch.LongTensor(indices) class_means_by_gloss[gloss] = {} - within_class_mean = calculate_mean_distances(scores, indices, indices, exclude_self=True) + within_class_mean = calculate_mean_distances( + scores, indices, indices, exclude_self=True + ) class_means_by_gloss[gloss]["in_class"] = within_class_mean complement_indices = all_indices[~torch.isin(all_indices, indices)] - without_class_mean = calculate_mean_distances(scores, indices, complement_indices) + without_class_mean = calculate_mean_distances( + scores, indices, complement_indices + ) class_means_by_gloss[gloss]["out_of_class"] = without_class_mean return class_means_by_gloss @@ -161,7 +174,9 @@ def calculate_class_means(gloss_indices, scores): # return within_class_means_by_gloss -def evaluate_signclip(emb_dir: Path, split_file: Path, out_path: Path, kind: str = "cosine"): +def evaluate_signclip( + emb_dir: Path, split_file: Path, out_path: Path, kind: str = "cosine" +): """ Evaluate SignCLIP embeddings using score_all. @@ -188,7 +203,9 @@ def evaluate_signclip(emb_dir: Path, split_file: Path, out_path: Path, kind: str # Step 3: Filter out rows without embeddings filter_start = time.perf_counter() - items_with_embeddings_df = split_df.dropna(subset=["embedding"]).reset_index(drop=True) + items_with_embeddings_df = split_df.dropna(subset=["embedding"]).reset_index( + drop=True + ) embeddings = items_with_embeddings_df["embedding"].tolist() filter_end = time.perf_counter() print(f"Filtering embeddings took {filter_end - filter_start:.4f} seconds") @@ -225,7 +242,9 @@ def evaluate_signclip(emb_dir: Path, split_file: Path, out_path: Path, kind: str print(f"We have a vocabulary of {len(unique_glosses)} glosses") gloss_indices = {} for gloss in items_with_embeddings_df["Gloss"].unique(): - gloss_indices[gloss] = items_with_embeddings_df.index[items_with_embeddings_df["Gloss"] == gloss].tolist() + gloss_indices[gloss] = items_with_embeddings_df.index[ + items_with_embeddings_df["Gloss"] == gloss + ].tolist() for gloss, indices in list(gloss_indices.items())[:10]: print(f"Here are the {len(indices)} indices for {gloss}:{indices}") @@ -238,7 +257,9 @@ def evaluate_signclip(emb_dir: Path, split_file: Path, out_path: Path, kind: str find_class_distances_end = time.perf_counter() - print(f"Finding within and without took {find_class_distances_end-find_class_distances_start}") + print( + f"Finding within and without took {find_class_distances_end-find_class_distances_start}" + ) analysis_end = time.perf_counter() analysis_duration = analysis_end - analysis_start @@ -257,14 +278,18 @@ def evaluate_signclip(emb_dir: Path, split_file: Path, out_path: Path, kind: str # Step 8: Save the scores and files to a compressed file save_start = time.perf_counter() - class_means_json = out_path.with_name(f"{out_path.stem}_class_means").with_suffix(".json") + class_means_json = out_path.with_name(f"{out_path.stem}_class_means").with_suffix( + ".json" + ) with open(class_means_json, "w") as f: print(f"Writing class means to {f}") json.dump(class_means, f) np.savez(out_path, scores=scores, files=files) save_end = time.perf_counter() print(f"Saving scores and files took {save_end - save_start:.4f} seconds") - print(f"Scores of shape {scores.shape} with files list of length {len(files)} saved to {out_path}") + print( + f"Scores of shape {scores.shape} with files list of length {len(files)} saved to {out_path}" + ) # Step 9: Read back the saved scores read_start = time.perf_counter() @@ -287,9 +312,20 @@ def evaluate_signclip(emb_dir: Path, split_file: Path, out_path: Path, kind: str def main(): - parser = argparse.ArgumentParser(description="Evaluate SignCLIP embeddings with score_all.") - parser.add_argument("emb_dir", type=Path, help="Path to the directory containing SignCLIP .npy files") - parser.add_argument("--split_file", type=Path, required=True, help="Path to the split CSV file (e.g., test.csv)") + parser = argparse.ArgumentParser( + description="Evaluate SignCLIP embeddings with score_all." + ) + parser.add_argument( + "emb_dir", + type=Path, + help="Path to the directory containing SignCLIP .npy files", + ) + parser.add_argument( + "--split_file", + type=Path, + required=True, + help="Path to the split CSV file (e.g., test.csv)", + ) parser.add_argument( "--kind", type=str, @@ -298,20 +334,31 @@ def main(): help="Type of distance metric to use (default: cosine)", ) - parser.add_argument("--out_path", type=Path, help="Where to save output distance npz matrix+file list") + parser.add_argument( + "--out_path", + type=Path, + help="Where to save output distance npz matrix+file list", + ) args = parser.parse_args() output_file = args.out_path if output_file is None: - output_file = Path(f"signclip_scores_{args.split_file.name}").with_suffix(".npz") + output_file = Path(f"signclip_scores_{args.split_file.name}").with_suffix( + ".npz" + ) if output_file.suffix != ".npz": output_file = Path(f"{output_file}.npz") print(f"Scores will be saved to {output_file}") - evaluate_signclip(emb_dir=args.emb_dir, split_file=args.split_file, out_path=output_file, kind=args.kind) + evaluate_signclip( + emb_dir=args.emb_dir, + split_file=args.split_file, + out_path=output_file, + kind=args.kind, + ) if __name__ == "__main__": diff --git a/pose_evaluation/examples/example_metric_construction.py b/pose_evaluation/examples/example_metric_construction.py index 66d3646..52b27e3 100644 --- a/pose_evaluation/examples/example_metric_construction.py +++ b/pose_evaluation/examples/example_metric_construction.py @@ -2,12 +2,17 @@ from pose_format import Pose from pose_evaluation.metrics.distance_metric import DistanceMetric from pose_evaluation.metrics.distance_measure import AggregatedPowerDistance -from pose_evaluation.metrics.base import BaseMetric from pose_evaluation.metrics.base_pose_metric import PoseMetric -from pose_evaluation.metrics.dtw_metric import DTWAggregatedPowerDistance +from pose_evaluation.metrics.base import BaseMetric +from pose_evaluation.metrics.dtw_metric import ( + DTWAggregatedPowerDistanceMeasure, + DTWAggregatedScipyDistanceMeasure, +) from pose_evaluation.metrics.test_distance_metric import get_poses -from pose_evaluation.utils.pose_utils import zero_pad_shorter_poses -from pose_evaluation.metrics.pose_processors import ZeroPadShorterPosesProcessor, get_standard_pose_processors +from pose_evaluation.metrics.pose_processors import ( + ZeroPadShorterPosesProcessor, + get_standard_pose_processors, +) if __name__ == "__main__": # Define file paths for test pose data @@ -40,39 +45,56 @@ # Define distance metrics mean_l1_metric = DistanceMetric( - "mean_l1_metric", distance_measure=AggregatedPowerDistance(1, 17) + "mean_l1_metric", + distance_measure=AggregatedPowerDistance(order=1, default_distance=17), ) metrics = [ - # BaseMetric("base"), - # DistanceMetric("PowerDistanceMetric", AggregatedPowerDistance(2, 1)), - # DistanceMetric("AnotherPowerDistanceMetric", AggregatedPowerDistance(1, 10)), - # mean_l1_metric, - # DistanceMetric( - # "max_l1_metric", - # AggregatedPowerDistance( - # order=1, aggregation_strategy="max", default_distance=0 - # ), - # ), - # DistanceMetric( - # "MeanL2Score", - # AggregatedPowerDistance( - # order=2, aggregation_strategy="mean", default_distance=0 - # ), - # pose_preprocessors=[ZeroPadShorterPosesProcessor()] - # ), - # DistanceMetric( - # "MeanL2Score", - # AggregatedPowerDistance( - # order=2, aggregation_strategy="mean", default_distance=0 - # ), - # ), - # PoseMetric() + BaseMetric("base"), + DistanceMetric( + "PowerDistanceMetric", AggregatedPowerDistance(order=2, default_distance=1) + ), + DistanceMetric( + "AnotherPowerDistanceMetric", + AggregatedPowerDistance("A custom name", order=1, default_distance=10), + ), + mean_l1_metric, + DistanceMetric( + "max_l1_metric", + AggregatedPowerDistance( + order=1, aggregation_strategy="max", default_distance=0 + ), + ), + DistanceMetric( + "MeanL2Score", + AggregatedPowerDistance( + order=2, aggregation_strategy="mean", default_distance=0 + ), + pose_preprocessors=[ZeroPadShorterPosesProcessor()], + ), + DistanceMetric( + "MeanL2Score", + AggregatedPowerDistance( + order=2, aggregation_strategy="mean", default_distance=0 + ), + ), + PoseMetric(), DistanceMetric( "DTWPowerDistance", - DTWAggregatedPowerDistance( - order=2, aggregation_strategy="mean", default_distance=0.0 + DTWAggregatedPowerDistanceMeasure( + aggregation_strategy="mean", default_distance=0.0, order=2 + ), + pose_preprocessors=get_standard_pose_processors( + zero_pad_shorter=False, reduce_holistic_to_face_and_upper_body=True + ), + ), + DistanceMetric( + "DTWScipyDistance", + DTWAggregatedScipyDistanceMeasure( + aggregation_strategy="mean", default_distance=0.0, metric="sqeuclidean" + ), + pose_preprocessors=get_standard_pose_processors( + zero_pad_shorter=False, reduce_holistic_to_face_and_upper_body=True ), - pose_preprocessors=get_standard_pose_processors(zero_pad_shorter=False) ), ] @@ -87,16 +109,22 @@ print(metric.get_signature().format(short=True)) try: - print(f"\nSCORE ALL with Signature (short):") - print(metric.score_all_with_signature(hypotheses, references, short=True, progress_bar=True)) + # + print("\nSCORE ALL with Signature (short):") + print( + metric.score_all_with_signature( + hypotheses, references, short=True, progress_bar=True + ) + ) + score = metric.score(poses[0], poses[1]) print(f"\nSCORE: {score}") + print("\nSCORE With Signature:") print(metric.score_with_signature(poses[0], poses[1])) - print(f"\nSCORE with Signature (short):") - print(metric.score_with_signature(poses[0], poses[1], short=True)) - + print("\nSCORE with Signature (short):") + print(metric.score_with_signature(poses[0], poses[1], short=True)) except NotImplementedError: print(f"{metric} score not implemented") diff --git a/pose_evaluation/metrics/base.py b/pose_evaluation/metrics/base.py index 2f33487..d125528 100644 --- a/pose_evaluation/metrics/base.py +++ b/pose_evaluation/metrics/base.py @@ -72,8 +72,28 @@ def __init__(self, name: str, score: float, signature: str) -> None: def __str__(self): return f"{self._signature} = {self.score}" + def format( + self, + width: int = 2, + score_only: bool = False, + ) -> str: + d = { + "name": self.name, + "score": float(f"{self.score:.{width}f}"), + "signature": self._signature, + } + + sc = f"{self.score:.{width}f}" + + full_score = f"{self._signature}" if self._signature else self.name + full_score = f"{full_score} = {sc}" + + if score_only: + return sc + return full_score + def __repr__(self): - return f"Score({super().__repr__()}, signature={repr(self._signature)})" + return self.format() class BaseMetric[T]: @@ -92,7 +112,10 @@ def score(self, hypothesis: T, reference: T) -> float: raise NotImplementedError def score_with_signature( - self, hypothesis: T, reference: T, short: bool = False + self, + hypothesis: T, + reference: T, + short: bool = False, ) -> Score: return Score( name=self.name, @@ -146,7 +169,7 @@ def score_all_with_signature( ] def __str__(self): - return f"{self.get_signature()}" + return self.get_signature().format() def get_signature(self) -> Signature: return self._SIGNATURE_TYPE(self.name, self.__dict__) diff --git a/pose_evaluation/metrics/base_pose_metric.py b/pose_evaluation/metrics/base_pose_metric.py index ba900b4..18ef01e 100644 --- a/pose_evaluation/metrics/base_pose_metric.py +++ b/pose_evaluation/metrics/base_pose_metric.py @@ -35,10 +35,32 @@ def __init__( else: self.pose_preprocessors = pose_preprocessors + def _pose_score(self, hypothesis: Pose, reference: Pose): + raise NotImplementedError("Subclasses must implement _pose_score") + + def score(self, hypothesis: Pose, reference: Pose): + hypothesis, reference = self.process_poses([hypothesis, reference]) + return self._pose_score(hypothesis, reference) + + def score_all( + self, hypotheses: Sequence[Pose], references: Sequence[Pose], progress_bar=False + ) -> List[List[float]]: + hyp_len = len(hypotheses) + ref_len = len(references) + + all_poses = self.process_poses(list(hypotheses) + list(references)) + + # Recover original lists if needed + hypotheses = all_poses[:hyp_len] + references = all_poses[hyp_len : hyp_len + ref_len] + return [ + [self.score(h, r) for r in references] + for h in tqdm(hypotheses, disable=not progress_bar or len(hypotheses) == 1) + ] + def score_with_signature( self, hypothesis: Pose, reference: Pose, short: bool = False ) -> PoseMetricScore: - hypothesis, reference = self.process_poses([hypothesis, reference]) return PoseMetricScore( name=self.name, score=self.score(hypothesis, reference), @@ -52,18 +74,14 @@ def score_all_with_signature( progress_bar=False, short: bool = False, ) -> list[list[Score]]: - hyp_len = len(hypotheses) - ref_len = len(references) - - all_poses = self.process_poses(hypotheses + references) - - # Recover original lists if needed - hypotheses = all_poses[:hyp_len] - references = all_poses[hyp_len : hyp_len + ref_len] return [ - [self.score_with_signature(h, r , short=short) for r in references] - for h in tqdm(hypotheses, desc="scoring:", disable=not progress_bar or len(hypotheses) == 1) + [self.score_with_signature(h, r, short=short) for r in references] + for h in tqdm( + hypotheses, + desc="scoring:", + disable=not progress_bar or len(hypotheses) == 1, + ) ] def process_poses(self, poses: Iterable[Pose], progress=False) -> List[Pose]: diff --git a/pose_evaluation/metrics/conftest.py b/pose_evaluation/metrics/conftest.py index c04f587..2d97484 100644 --- a/pose_evaluation/metrics/conftest.py +++ b/pose_evaluation/metrics/conftest.py @@ -1,9 +1,10 @@ import shutil from pathlib import Path -from typing import Callable, Union +from typing import Callable, List, Union import torch import numpy as np import pytest +from pose_format import Pose @pytest.fixture(scope="session", autouse=True) @@ -18,19 +19,27 @@ def clean_test_artifacts(): @pytest.fixture(name="distance_matrix_shape_checker") -def fixture_distance_matrix_shape_checker() -> Callable[[torch.Tensor, torch.Tensor], None]: +def fixture_distance_matrix_shape_checker() -> ( + Callable[[torch.Tensor, torch.Tensor], None] +): def _check_shape(hyp_count: int, ref_count: int, distance_matrix: torch.Tensor): - expected_shape = torch.Size([hyp_count, ref_count]) - assert ( - distance_matrix.shape == expected_shape - ), f"For M={hyp_count} hypotheses, N={ref_count} references, Distance Matrix should be MxN={expected_shape}. Instead, received {distance_matrix.shape}" + # "line too long" + msg = ( + f"For M={hyp_count} hypotheses, N={ref_count} references, " + f"Distance Matrix should be MxN={expected_shape}. " + f"Instead, received {distance_matrix.shape}" + ) + + assert distance_matrix.shape == expected_shape, msg - return _check_shape + return _check_shape # type: ignore @pytest.fixture(name="distance_range_checker") -def fixture_distance_range_checker() -> Callable[[Union[torch.Tensor, np.ndarray], float, float], None]: +def fixture_distance_range_checker() -> ( + Callable[[Union[torch.Tensor, np.ndarray], float, float], None] +): def _check_range( distances: Union[torch.Tensor, np.ndarray], min_val: float = 0, @@ -41,10 +50,22 @@ def _check_range( # Use np.isclose for comparisons with tolerance assert ( - np.isclose(min_distance, min_val, atol=1e-6) or min_val <= min_distance <= max_val + np.isclose(min_distance, min_val, atol=1e-6) + or min_val <= min_distance <= max_val ), f"Minimum distance ({min_distance}) is outside the expected range [{min_val}, {max_val}]" assert ( - np.isclose(max_distance, max_val, atol=1e-6) or min_val <= max_distance <= max_val + np.isclose(max_distance, max_val, atol=1e-6) + or min_val <= max_distance <= max_val ), f"Maximum distance ({max_distance}) is outside the expected range [{min_val}, {max_val}]" return _check_range + + +@pytest.fixture +def real_pose_files() -> List[Pose]: + test_files_folder = Path("pose_evaluation") / "utils" / "test" / "test_data" + real_pose_files_list = [ + Pose.read(test_file.read_bytes()) + for test_file in test_files_folder.glob("*.pose") + ] + return real_pose_files_list diff --git a/pose_evaluation/metrics/distance_measure.py b/pose_evaluation/metrics/distance_measure.py index 75c7671..d7bbd1f 100644 --- a/pose_evaluation/metrics/distance_measure.py +++ b/pose_evaluation/metrics/distance_measure.py @@ -4,8 +4,10 @@ AggregationStrategy = Literal["max", "min", "mean", "sum"] + class DistanceMeasureSignature(Signature): """Signature for distance measure metrics.""" + def __init__(self, name: str, args: Dict[str, Any]) -> None: super().__init__(name=name, args=args) self.update_abbr("distance", "dist") @@ -14,29 +16,36 @@ def __init__(self, name: str, args: Dict[str, Any]) -> None: class DistanceMeasure: """Abstract base class for distance measures.""" + _SIGNATURE_TYPE = DistanceMeasureSignature - def __init__(self, name: str) -> None: + def __init__(self, name: str, default_distance=0.0) -> None: self.name = name + self.default_distance = default_distance def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> float: """ Compute the distance between hypothesis and reference data. - + This method should be implemented by subclasses. """ raise NotImplementedError - - def _get_keypoint_trajectories(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray): + + def _get_keypoint_trajectories( + self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray + ): # frames, persons, keypoint for keypoint_idx in range(hyp_data.shape[2]): - hyp_trajectory, ref_trajectory =hyp_data[:, 0, keypoint_idx, :], ref_data[:, 0, keypoint_idx, :] + hyp_trajectory, ref_trajectory = ( + hyp_data[:, 0, keypoint_idx, :], + ref_data[:, 0, keypoint_idx, :], + ) yield hyp_trajectory, ref_trajectory def __call__(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> float: return self.get_distance(hyp_data, ref_data) - - def _calculate_distances( + + def _calculate_pointwise_distances( self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray ) -> ma.MaskedArray: raise NotImplementedError @@ -48,6 +57,7 @@ def get_signature(self) -> Signature: class PowerDistanceSignature(DistanceMeasureSignature): """Signature for power distance measures.""" + def __init__(self, name: str, args: Dict[str, Any]) -> None: super().__init__(name=name, args=args) self.update_signature_and_abbr("order", "ord", args) @@ -55,32 +65,20 @@ def __init__(self, name: str, args: Dict[str, Any]) -> None: self.update_signature_and_abbr("aggregation_strategy", "agg", args) -class AggregatedPowerDistance(DistanceMeasure): - """Aggregated power distance metric using a specified aggregation strategy.""" - _SIGNATURE_TYPE = PowerDistanceSignature - +class AggregatedDistanceMeasure(DistanceMeasure): def __init__( self, - order: int = 2, + name: str, default_distance: float = 0.0, aggregation_strategy: AggregationStrategy = "mean", ) -> None: - """ - Initialize the aggregated power distance metric. - - :param order: The exponent to which differences are raised. - :param default_distance: The value to fill in for masked entries. - :param aggregation_strategy: Strategy to aggregate computed distances. - """ - super().__init__(name="power_distance") - self.power = float(order) - self.default_distance = default_distance + super().__init__(name, default_distance=default_distance) self.aggregation_strategy = aggregation_strategy def _aggregate(self, distances: ma.MaskedArray) -> float: """ Aggregate computed distances using the specified strategy. - + :param distances: A masked array of computed distances. :return: A single aggregated distance value. """ @@ -97,19 +95,51 @@ def _aggregate(self, distances: ma.MaskedArray) -> float: f"Aggregation Strategy {self.aggregation_strategy} not implemented" ) - def _calculate_distances( + def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> float: + """Compute and aggregate the distance between hypothesis and reference data.""" + calculated = self._calculate_pointwise_distances(hyp_data, ref_data) + return self._aggregate(calculated) + + +class AggregatedPowerDistance(AggregatedDistanceMeasure): + """Aggregated power distance metric using a specified aggregation strategy.""" + + _SIGNATURE_TYPE = PowerDistanceSignature + + def __init__( + self, + name="AggregatedPowerDistance", + order: int = 2, + default_distance: float = 0.0, + aggregation_strategy: AggregationStrategy = "mean", + ) -> None: + """ + Initialize the aggregated power distance metric. + + :param order: The exponent to which differences are raised. + :param default_distance: The value to fill in for masked entries. + :param aggregation_strategy: Strategy to aggregate computed distances. + """ + super().__init__( + name=name, + aggregation_strategy=aggregation_strategy, + default_distance=default_distance, + ) + self.power = float(order) + + def _calculate_pointwise_distances( self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray ) -> ma.MaskedArray: """ Compute element-wise distances between hypothesis and reference data. - + Steps: 1. Compute the absolute differences. 2. Raise the differences to the specified power. 3. Sum the powered differences along the last axis. 4. Extract the root corresponding to the power. 5. Fill masked values with the default distance. - + :param hyp_data: Hypothesis data as a masked array. :param ref_data: Reference data as a masked array. :return: A masked array of computed distances. @@ -119,8 +149,3 @@ def _calculate_distances( summed_results = ma.sum(raised_to_power, axis=-1, keepdims=True) roots = ma.power(summed_results, 1 / self.power) return ma.filled(roots, self.default_distance) - - def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> float: - """Compute and aggregate the distance between hypothesis and reference data.""" - calculated = self._calculate_distances(hyp_data, ref_data) - return self._aggregate(calculated) diff --git a/pose_evaluation/metrics/distance_metric.py b/pose_evaluation/metrics/distance_metric.py index 0c06a10..cdf7c11 100644 --- a/pose_evaluation/metrics/distance_metric.py +++ b/pose_evaluation/metrics/distance_metric.py @@ -8,8 +8,15 @@ class DistanceMetric(PoseMetric): """Computes the distance between two poses using the provided distance measure.""" - def __init__(self, name: str, distance_measure: DistanceMeasure, pose_preprocessors: List[PoseProcessor] | None = None) -> None: - super().__init__(name=name, higher_is_better=False, pose_preprocessors=pose_preprocessors) + def __init__( + self, + name: str, + distance_measure: DistanceMeasure, + pose_preprocessors: List[PoseProcessor] | None = None, + ) -> None: + super().__init__( + name=name, higher_is_better=False, pose_preprocessors=pose_preprocessors + ) self.distance_measure = distance_measure def score(self, hypothesis: Pose, reference: Pose) -> float: diff --git a/pose_evaluation/metrics/dtw_metric.py b/pose_evaluation/metrics/dtw_metric.py index 91aaff3..ac4ecd1 100644 --- a/pose_evaluation/metrics/dtw_metric.py +++ b/pose_evaluation/metrics/dtw_metric.py @@ -1,35 +1,97 @@ -import numpy as np from fastdtw import fastdtw +from scipy.spatial.distance import cdist import numpy.ma as ma from tqdm import tqdm from pose_evaluation.metrics.distance_measure import ( - AggregatedPowerDistance, + AggregatedDistanceMeasure, AggregationStrategy, ) -class DTWAggregatedPowerDistance(AggregatedPowerDistance): +class DTWAggregatedDistanceMeasure(AggregatedDistanceMeasure): def __init__( self, - order: int = 2, + name="DTWAggregatedDistanceMeasure", default_distance: float = 0, aggregation_strategy: AggregationStrategy = "mean", ) -> None: - super().__init__(order, default_distance, aggregation_strategy) + super().__init__( + name=name, + default_distance=default_distance, + aggregation_strategy=aggregation_strategy, + ) def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> float: keypoint_count = hyp_data.shape[ 2 ] # Assuming shape: (frames, person, keypoints, xyz) - traj_distances = np.empty(keypoint_count) # Preallocate a NumPy array + traj_distances = ma.empty(keypoint_count) # Preallocate a NumPy array for i, (hyp_traj, ref_traj) in tqdm( enumerate(self._get_keypoint_trajectories(hyp_data, ref_data)), desc="getting dtw distances for trajectories", total=keypoint_count, ): - distance, _ = fastdtw(hyp_traj, ref_traj, dist=self._calculate_distances) + distance, _ = fastdtw( + hyp_traj, ref_traj, dist=self._calculate_pointwise_distances + ) traj_distances[i] = distance # Store distance in the preallocated array - + traj_distances = ma.array(traj_distances) return self._aggregate(traj_distances) + + def _calculate_pointwise_distances( + self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray + ) -> ma.MaskedArray: + raise NotImplementedError( + "_calculate_pointwise_distances must be a callable that can be passed to fastdtw" + ) + + +class DTWAggregatedPowerDistanceMeasure(DTWAggregatedDistanceMeasure): + def __init__( + self, + name="DTWAggregatedDistanceMeasure", + default_distance: float = 0, + aggregation_strategy: AggregationStrategy = "mean", + order=2, + ) -> None: + super().__init__( + name=name, + default_distance=default_distance, + aggregation_strategy=aggregation_strategy, + ) + self.power = order + + def _calculate_pointwise_distances( + self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray + ) -> ma.MaskedArray: + diffs = ma.abs(hyp_data - ref_data) + raised_to_power = ma.power(diffs, self.power) + summed_results = ma.sum(raised_to_power, axis=-1, keepdims=True) + roots = ma.power(summed_results, 1 / self.power) + return ma.filled(roots, self.default_distance) + + +class DTWAggregatedScipyDistanceMeasure(DTWAggregatedDistanceMeasure): + def __init__( + self, + name="DTWAggregatedDistanceMeasure", + default_distance: float = 0, + aggregation_strategy: AggregationStrategy = "mean", + metric: str = "euclidean", + ) -> None: + super().__init__( + name=name, + default_distance=default_distance, + aggregation_strategy=aggregation_strategy, + ) + self.metric = metric + + def _calculate_pointwise_distances( + self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray + ) -> ma.MaskedArray: + hyp_data = hyp_data.reshape(1, -1) # Adds a new leading dimension) + ref_data = ref_data.reshape(1, -1) + assert ref_data.ndim == 2, ref_data.shape + return cdist(hyp_data, ref_data, metric=self.metric) # type: ignore "no overloads match" but it works fine diff --git a/pose_evaluation/metrics/embedding_distance_metric.py b/pose_evaluation/metrics/embedding_distance_metric.py index 6044faa..3643fd2 100644 --- a/pose_evaluation/metrics/embedding_distance_metric.py +++ b/pose_evaluation/metrics/embedding_distance_metric.py @@ -84,7 +84,9 @@ def _to_batch_tensor_on_device(self, data: TensorConvertableType) -> Tensor: # https://stackoverflow.com/questions/55050717/converting-list-of-tensors-to-tensors-pytorch data = torch.stack(data) - return st_util._convert_to_batch_tensor(data).to(device=self.device, dtype=self.dtype) + return st_util._convert_to_batch_tensor(data).to( + device=self.device, dtype=self.dtype + ) def score( self, @@ -123,7 +125,9 @@ def score_all( hypotheses = self._to_batch_tensor_on_device(hypotheses) references = self._to_batch_tensor_on_device(references) except RuntimeError as e: - raise TypeError(f"Inputs must support conversion to device tensors: {e}") from e + raise TypeError( + f"Inputs must support conversion to device tensors: {e}" + ) from e assert ( hypotheses.ndim == 2 and references.ndim == 2 @@ -131,7 +135,9 @@ def score_all( return self._metric_dispatch[self.kind](hypotheses, references) - def dot_product(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: + def dot_product( + self, hypotheses: TensorConvertableType, references: TensorConvertableType + ) -> Tensor: """ Compute the dot product between embeddings. Uses sentence_transformers.util.dot_score @@ -139,14 +145,18 @@ def dot_product(self, hypotheses: TensorConvertableType, references: TensorConve # https://stackoverflow.com/questions/73924697/whats-the-difference-between-torch-mm-torch-matmul-and-torch-mul return st_util.dot_score(hypotheses, references) - def euclidean_similarities(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: + def euclidean_similarities( + self, hypotheses: TensorConvertableType, references: TensorConvertableType + ) -> Tensor: """ Returns the negative L2 norm/euclidean distances, which is what sentence-transformers uses for similarities. Uses sentence_transformers.util.euclidean_sim """ return st_util.euclidean_sim(hypotheses, references) - def euclidean_distances(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: + def euclidean_distances( + self, hypotheses: TensorConvertableType, references: TensorConvertableType + ) -> Tensor: """ Seeing as how sentence-transformers just negates the distances to get "similarities", We can re-negate to get them positive again. @@ -154,7 +164,9 @@ def euclidean_distances(self, hypotheses: TensorConvertableType, references: Ten """ return -self.euclidean_similarities(hypotheses, references) - def cosine_similarities(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: + def cosine_similarities( + self, hypotheses: TensorConvertableType, references: TensorConvertableType + ) -> Tensor: """ Calculates cosine similarities, which can be thought of as the angle between two embeddings. The min value is -1 (least similar/pointing directly away), and the max is 1 (exactly the same angle). @@ -162,21 +174,27 @@ def cosine_similarities(self, hypotheses: TensorConvertableType, references: Ten """ return st_util.cos_sim(hypotheses, references) - def cosine_distances(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: + def cosine_distances( + self, hypotheses: TensorConvertableType, references: TensorConvertableType + ) -> Tensor: """ Converts cosine similarities to distances by simply subtracting from 1. Max distance is 2, min distance is 0. """ return 1 - self.cosine_similarities(hypotheses, references) - def manhattan_similarities(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: + def manhattan_similarities( + self, hypotheses: TensorConvertableType, references: TensorConvertableType + ) -> Tensor: """ Get the L1/Manhattan similarities, aka negative distances. Uses sentence_transformers.util.manhattan_sim """ return st_util.manhattan_sim(hypotheses, references) - def manhattan_distances(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: + def manhattan_distances( + self, hypotheses: TensorConvertableType, references: TensorConvertableType + ) -> Tensor: """ Convert Manhattan similarities to distances. Sentence transformers defines similarity as negative distances. diff --git a/pose_evaluation/metrics/pose_processors.py b/pose_evaluation/metrics/pose_processors.py index 94c91cb..35ff621 100644 --- a/pose_evaluation/metrics/pose_processors.py +++ b/pose_evaluation/metrics/pose_processors.py @@ -3,10 +3,9 @@ from tqdm import tqdm from pose_format import Pose -from pose_format.utils.generic import pose_hide_legs +from pose_format.utils.generic import pose_hide_legs, reduce_holistic from pose_evaluation.metrics.base import Signature from pose_evaluation.utils.pose_utils import ( - get_face_and_hands_from_pose, zero_pad_shorter_poses, reduce_poses_to_intersection, ) @@ -34,13 +33,16 @@ def __repr__(self) -> str: return f"{self.get_signature()}" def __str__(self) -> str: - return f"{self.get_signature()}" + return self.get_signature().format() def process_pose(self, pose: Pose) -> Pose: - return pose + raise NotImplementedError(f"process_pose not implemented for {self.name}") def process_poses(self, poses: Iterable[Pose], progress=False) -> List[Pose]: - return [self.process_pose(pose) for pose in tqdm(poses, desc=f"{self.name}", disable=not progress)] + return [ + self.process_pose(pose) + for pose in tqdm(poses, desc=f"{self.name}", disable=not progress) + ] def get_signature(self) -> Signature: return self._SIGNATURE_TYPE(self.name, self.__dict__) @@ -97,15 +99,38 @@ class ZeroPadShorterPosesProcessor(PoseProcessor): def __init__(self) -> None: super().__init__(name="zero_pad_shorter_sequence") + def process_pose(self, pose: Pose) -> Pose: + return pose # intersection with itself + def process_poses(self, poses: Iterable[Pose], progress=False) -> List[Pose]: return zero_pad_shorter_poses(poses) +class ReduceHolisticPoseProcessor(PoseProcessor): + def __init__(self) -> None: + super().__init__(name="reduce_holistic") + + def process_pose(self, pose: Pose) -> Pose: + return reduce_holistic(pose) + + +class ZeroFillMaskedValuesPoseProcessor(PoseProcessor): + def __init__(self) -> None: + super().__init__(name="reduce_holistic") + + def process_pose(self, pose: Pose) -> Pose: + pose = pose.copy() + pose.body = pose.body.zero_filled() + return pose + + def get_standard_pose_processors( normalize_poses: bool = True, reduce_poses_to_common_components: bool = True, remove_world_landmarks=True, remove_legs=True, + reduce_holistic_to_face_and_upper_body=False, + zero_fill_masked=False, zero_pad_shorter=True, ) -> List[PoseProcessor]: pose_processors = [] @@ -122,6 +147,12 @@ def get_standard_pose_processors( if remove_legs: pose_processors.append(HideLegsPosesProcessor()) + if reduce_holistic_to_face_and_upper_body: + pose_processors.append(ReduceHolisticPoseProcessor()) + + if zero_fill_masked: + pose_processors.append(ZeroFillMaskedValuesPoseProcessor()) + if zero_pad_shorter: pose_processors.append(ZeroPadShorterPosesProcessor()) diff --git a/pose_evaluation/metrics/segmented_metric.py b/pose_evaluation/metrics/segmented_metric.py index 0ebb821..512ffa8 100644 --- a/pose_evaluation/metrics/segmented_metric.py +++ b/pose_evaluation/metrics/segmented_metric.py @@ -11,11 +11,15 @@ class SegmentedPoseMetric(PoseMetric): def __init__(self, isolated_metric: PoseMetric): - super().__init__("SegmentedPoseMetric", higher_is_better=isolated_metric.higher_is_better) + super().__init__( + "SegmentedPoseMetric", higher_is_better=isolated_metric.higher_is_better + ) self.isolated_metric = isolated_metric - model_path = resources.path("sign_language_segmentation", "dist/model_E1s-1.pth") + model_path = resources.path( + "sign_language_segmentation", "dist/model_E1s-1.pth" + ) self.segmentation_model = load_model(model_path) # pylint: disable=too-many-locals @@ -24,7 +28,9 @@ def score(self, hypothesis: Pose, reference: Pose) -> float: processed_hypothesis = process_pose(hypothesis) processed_reference = process_pose(reference) # Predict segments BIO - hypothesis_probs = predict(self.segmentation_model, processed_hypothesis)["sign"] + hypothesis_probs = predict(self.segmentation_model, processed_hypothesis)[ + "sign" + ] reference_probs = predict(self.segmentation_model, processed_reference)["sign"] # Convert to discrete segments hypothesis_signs = probs_to_segments(hypothesis_probs, 60, 50) @@ -43,7 +49,9 @@ def score(self, hypothesis: Pose, reference: Pose) -> float: reference_signs += [(0, 0)] * (max_length - len(reference_signs)) # Match each hypothesis sign with each reference sign - cost_matrix = self.isolated_metric.score_all(hypothesis_signs, reference_signs, progress_bar=False) + cost_matrix = self.isolated_metric.score_all( + hypothesis_signs, reference_signs, progress_bar=False + ) cost_tensor = np.array(cost_matrix) if not self.isolated_metric.higher_is_better: cost_tensor = 1 - cost_tensor diff --git a/pose_evaluation/metrics/test_dtw_metric.py b/pose_evaluation/metrics/test_dtw_metric.py new file mode 100644 index 0000000..9813107 --- /dev/null +++ b/pose_evaluation/metrics/test_dtw_metric.py @@ -0,0 +1,47 @@ +import unittest +from pose_evaluation.metrics.distance_metric import DistanceMetric +from pose_evaluation.metrics.pose_processors import get_standard_pose_processors +from pose_evaluation.metrics.test_distance_metric import get_poses +from pose_evaluation.metrics.dtw_metric import DTWAggregatedPowerDistanceMeasure + + +class TestDTWMetricL1(unittest.TestCase): + def setUp(self): + distance_measure = DTWAggregatedPowerDistanceMeasure( + order=1, aggregation_strategy="mean", default_distance=0.0 + ) + self.metric = DistanceMetric( + name="DTWPowerDistance", + distance_measure=distance_measure, + pose_preprocessors=get_standard_pose_processors( + normalize_poses=False, # no shoulders, will crash + remove_world_landmarks=False, # there are none, will crash + reduce_poses_to_common_components=False, # removes all components, there are none in common + remove_legs=False, # there are none, it will crash + zero_pad_shorter=False, # defeats the point of dtw + reduce_holistic_to_face_and_upper_body=False, + ), + ) + self.poses_supplier = get_poses + + def test_score_equal_length(self): + hypothesis, reference = self.poses_supplier(3, 3) + expected_distance = 9 + + score = self.metric.score(hypothesis, reference) + self.assertIsInstance(score, float) + self.assertAlmostEqual(score, expected_distance) + + def test_score_unequal_length(self): + hypothesis, reference = self.poses_supplier(2, 3, people1=1, people2=1) + + # fastdtw creates mappings such that they are effectively both length 3. + # Each of the point distances are (0,0,0) to (1,1,1), so 1+1+1=3 + # 3 pointwise distances, so 3*3 = 9 + # Those are then aggregated by mean aggregation across keypoints, but they're all 9. + # so mean of all 9s is 9 + expected_distance = 9 + + score = self.metric.score(hypothesis, reference) + self.assertIsInstance(score, float) + self.assertAlmostEqual(score, expected_distance) diff --git a/pose_evaluation/metrics/test_embedding_distance_metric.py b/pose_evaluation/metrics/test_embedding_distance_metric.py index 0f08bb9..0ac9623 100644 --- a/pose_evaluation/metrics/test_embedding_distance_metric.py +++ b/pose_evaluation/metrics/test_embedding_distance_metric.py @@ -54,7 +54,9 @@ def test_shape_checker(distance_matrix_shape_checker): def call_and_call_with_inputs_swapped( - hyps: torch.Tensor, refs: torch.Tensor, scoring_function: Callable[[torch.Tensor, torch.Tensor], torch.Tensor] + hyps: torch.Tensor, + refs: torch.Tensor, + scoring_function: Callable[[torch.Tensor, torch.Tensor], torch.Tensor], ) -> Tuple[torch.Tensor, torch.Tensor]: score1 = scoring_function(hyps, refs) score2 = scoring_function(refs, hyps) @@ -88,7 +90,9 @@ def save_and_plot_distances(distances, matrix_name, num_points, dim): distances = distances.cpu() test_artifacts_dir = Path(__file__).parent / "tests" - output_path = test_artifacts_dir / f"distance_matrix_{matrix_name}_{num_points}_{dim}D.csv" + output_path = ( + test_artifacts_dir / f"distance_matrix_{matrix_name}_{num_points}_{dim}D.csv" + ) np.savetxt(output_path, distances.numpy(), delimiter=",", fmt="%.4f") print(f"Distance matrix saved to {output_path}") @@ -132,7 +136,9 @@ def generate_orthogonal_rows_with_repeats(num_rows: int, dim: int) -> torch.Tens @ orthogonal_rows / torch.norm(orthogonal_rows, dim=1, keepdim=True) ** 2 ) - orthogonal_rows = torch.cat([orthogonal_rows, random_vector / torch.norm(random_vector)]) + orthogonal_rows = torch.cat( + [orthogonal_rows, random_vector / torch.norm(random_vector)] + ) if num_rows > dim: orthogonal_rows = orthogonal_rows.repeat(num_rows // dim + 1, 1)[:num_rows] return orthogonal_rows @@ -163,7 +169,9 @@ def generate_orthogonal_rows_in_pairs(num_pairs: int, dim: int) -> torch.Tensor: second_vector = second_vector / torch.norm(second_vector) # Normalize # Concatenate the pair to the result - orthogonal_rows = torch.cat([orthogonal_rows, first_vector, second_vector], dim=0) + orthogonal_rows = torch.cat( + [orthogonal_rows, first_vector, second_vector], dim=0 + ) return orthogonal_rows @@ -209,7 +217,9 @@ def test_score_symmetric(cosine_metric: EmbeddingDistanceMetric) -> None: assert pytest.approx(score1) == score2, "Score should be symmetric." -def test_score_with_path(cosine_metric: EmbeddingDistanceMetric, tmp_path: Path) -> None: +def test_score_with_path( + cosine_metric: EmbeddingDistanceMetric, tmp_path: Path +) -> None: """Test that score works with embeddings loaded from file paths.""" emb1 = random_tensor(768).cpu().numpy() # Save as NumPy for file storage emb2 = random_tensor(768).cpu().numpy() @@ -225,10 +235,14 @@ def test_score_with_path(cosine_metric: EmbeddingDistanceMetric, tmp_path: Path) emb2_loaded = torch.tensor(np.load(file2), dtype=torch.float32, device=DEVICE) score = cosine_metric.score(emb1_loaded, emb2_loaded) - expected_score = cosine_metric.score(torch.tensor(emb1, device=DEVICE), torch.tensor(emb2, device=DEVICE)) + expected_score = cosine_metric.score( + torch.tensor(emb1, device=DEVICE), torch.tensor(emb2, device=DEVICE) + ) logger.info(f"Score from file: {score}, Direct score: {expected_score}") - assert pytest.approx(score) == expected_score, "Score with paths should match direct computation." + assert ( + pytest.approx(score) == expected_score + ), "Score with paths should match direct computation." def test_score_all_against_self( @@ -243,35 +257,55 @@ def test_score_all_against_self( distance_range_checker(scores, min_val=0, max_val=2) assert torch.allclose( - torch.diagonal(scores), torch.zeros(len(embeddings), dtype=scores.dtype), atol=1e-6 + torch.diagonal(scores), + torch.zeros(len(embeddings), dtype=scores.dtype), + atol=1e-6, ), "Self-comparison scores should be zero for cosine distance." - logger.info(f"Score matrix shape: {scores.shape}, Diagonal values: {torch.diagonal(scores)}") + logger.info( + f"Score matrix shape: {scores.shape}, Diagonal values: {torch.diagonal(scores)}" + ) -def test_score_all_with_one_vs_batch(cosine_metric, distance_range_checker, distance_matrix_shape_checker): +def test_score_all_with_one_vs_batch( + cosine_metric, distance_range_checker, distance_matrix_shape_checker +): hyps = [np.random.rand(768) for _ in range(3)] refs = np.random.rand(768) expected_shape = (len(hyps), 1) call_with_both_input_orders_and_do_standard_checks( - hyps, refs, cosine_metric.score_all, distance_range_checker, distance_matrix_shape_checker, expected_shape + hyps, + refs, + cosine_metric.score_all, + distance_range_checker, + distance_matrix_shape_checker, + expected_shape, ) -def test_score_all_with_different_sizes(cosine_metric, distance_range_checker, distance_matrix_shape_checker): +def test_score_all_with_different_sizes( + cosine_metric, distance_range_checker, distance_matrix_shape_checker +): """Test score_all with different sizes for hypotheses and references.""" hyps = [np.random.rand(768) for _ in range(3)] refs = [np.random.rand(768) for _ in range(5)] expected_shape = (len(hyps), len(refs)) call_with_both_input_orders_and_do_standard_checks( - hyps, refs, cosine_metric.score_all, distance_range_checker, distance_matrix_shape_checker, expected_shape + hyps, + refs, + cosine_metric.score_all, + distance_range_checker, + distance_matrix_shape_checker, + expected_shape, ) -def test_score_with_invalid_input_mismatched_embedding_sizes(cosine_metric: EmbeddingDistanceMetric) -> None: +def test_score_with_invalid_input_mismatched_embedding_sizes( + cosine_metric: EmbeddingDistanceMetric, +) -> None: hyp = random_tensor(768) ref = random_tensor(769) @@ -281,7 +315,9 @@ def test_score_with_invalid_input_mismatched_embedding_sizes(cosine_metric: Embe call_and_call_with_inputs_swapped(hyp, ref, cosine_metric.score) -def test_score_with_invalid_input_single_number(cosine_metric: EmbeddingDistanceMetric) -> None: +def test_score_with_invalid_input_single_number( + cosine_metric: EmbeddingDistanceMetric, +) -> None: hyp = random_tensor(768) for ref in range(-2, 2): with pytest.raises(AssertionError, match="score_all received non-2D input"): @@ -292,7 +328,9 @@ def test_score_with_invalid_input_single_number(cosine_metric: EmbeddingDistance logger.info("Invalid input successfully crashed as expected.") -def test_score_with_invalid_input_string(cosine_metric: EmbeddingDistanceMetric) -> None: +def test_score_with_invalid_input_string( + cosine_metric: EmbeddingDistanceMetric, +) -> None: hyp = "invalid input" ref = random_tensor(768) with pytest.raises(TypeError, match="invalid data type 'str'"): @@ -308,7 +346,9 @@ def test_score_with_invalid_input_bool(cosine_metric: EmbeddingDistanceMetric) - # TODO: why does a bool make it all the way there? -def test_score_with_invalid_input_empty_containers(cosine_metric: EmbeddingDistanceMetric) -> None: +def test_score_with_invalid_input_empty_containers( + cosine_metric: EmbeddingDistanceMetric, +) -> None: """Test the metric with invalid inputs.""" emb1 = random_tensor(768) invalid_inputs = ["", [], {}, tuple(), set()] @@ -362,7 +402,9 @@ def test_score_all_list_of_lists_of_floats( ) -def test_score_all_list_of_tensor_input(cosine_metric, distance_range_checker, distance_matrix_shape_checker): +def test_score_all_list_of_tensor_input( + cosine_metric, distance_range_checker, distance_matrix_shape_checker +): """Test score_all function with List of torch.Tensor inputs.""" hyps = [torch.rand(768) for _ in range(5)] refs = [torch.rand(768) for _ in range(5)] @@ -401,11 +443,16 @@ def test_score_all_list_of_ndarray_input( def test_device_handling(cosine_metric): """Test device handling for the metric.""" - assert cosine_metric.device.type in ["cuda", "cpu"], "Device should be either 'cuda' or 'cpu'." + assert cosine_metric.device.type in [ + "cuda", + "cpu", + ], "Device should be either 'cuda' or 'cpu'." if torch.cuda.is_available(): assert cosine_metric.device.type == "cuda", "Should use 'cuda' when available." else: - assert cosine_metric.device.type == "cpu", "Should use 'cpu' when CUDA is unavailable." + assert ( + cosine_metric.device.type == "cpu" + ), "Should use 'cpu' when CUDA is unavailable." def test_score_mixed_input_types(cosine_metric): @@ -414,10 +461,14 @@ def test_score_mixed_input_types(cosine_metric): emb2 = torch.rand(768) all_scores = call_and_call_with_inputs_swapped(emb1, emb2, cosine_metric.score) - assert all([isinstance(score, float) for score in all_scores]), "Output should be a float." + assert all( + [isinstance(score, float) for score in all_scores] + ), "Output should be a float." -def test_score_all_mixed_input_types(cosine_metric, distance_range_checker, distance_matrix_shape_checker): +def test_score_all_mixed_input_types( + cosine_metric, distance_range_checker, distance_matrix_shape_checker +): """Test score function with mixed input types.""" hyps = np.random.rand(5, 768) refs = torch.rand(3, 768) @@ -435,10 +486,18 @@ def test_score_all_mixed_input_types(cosine_metric, distance_range_checker, dist @pytest.mark.parametrize("num_points, dim", [(16, 2)]) -def test_unit_circle_points(cosine_metric, num_points, dim, distance_range_checker, distance_matrix_shape_checker): +def test_unit_circle_points( + cosine_metric, + num_points, + dim, + distance_range_checker, + distance_matrix_shape_checker, +): embeddings = generate_unit_circle_points(num_points, dim) distances = cosine_metric.score_all(embeddings, embeddings) - save_and_plot_distances(distances=distances, matrix_name="Unit Circle", num_points=num_points, dim=dim) + save_and_plot_distances( + distances=distances, matrix_name="Unit Circle", num_points=num_points, dim=dim + ) distance_range_checker(distances, min_val=0, max_val=2) # Check distance range distance_matrix_shape_checker(embeddings.shape[0], embeddings.shape[0], distances) @@ -448,7 +507,10 @@ def test_orthogonal_rows_with_repeats_2d(cosine_metric, num_points, dim): embeddings = generate_orthogonal_rows_with_repeats(num_points, dim) distances = cosine_metric.score_all(embeddings, embeddings) save_and_plot_distances( - distances=distances, matrix_name="Orthogonal Rows (with repeats)", num_points=num_points, dim=dim + distances=distances, + matrix_name="Orthogonal Rows (with repeats)", + num_points=num_points, + dim=dim, ) # Create expected pattern directly within the test function @@ -466,7 +528,11 @@ def test_orthogonal_rows_with_repeats_2d(cosine_metric, num_points, dim): @pytest.mark.parametrize("num_points, dim", [(20, 2)]) def test_orthogonal_rows_in_pairs( - cosine_metric, num_points, dim, distance_range_checker, distance_matrix_shape_checker + cosine_metric, + num_points, + dim, + distance_range_checker, + distance_matrix_shape_checker, ): embeddings = generate_orthogonal_rows_in_pairs(num_points, dim) distances = cosine_metric.score_all(embeddings, embeddings) @@ -476,16 +542,32 @@ def test_orthogonal_rows_in_pairs( @pytest.mark.parametrize("num_points, dim", [(10, 5)]) -def test_ones_tensor(cosine_metric, num_points, dim, distance_range_checker, distance_matrix_shape_checker): +def test_ones_tensor( + cosine_metric, + num_points, + dim, + distance_range_checker, + distance_matrix_shape_checker, +): embeddings = generate_ones_tensor(num_points, dim) distances = cosine_metric.score_all(embeddings, embeddings) save_and_plot_distances(distances, "ones_tensor", num_points, dim) - distance_range_checker(distances, min_val=0, max_val=0) # Expect all distances to be 0 + distance_range_checker( + distances, min_val=0, max_val=0 + ) # Expect all distances to be 0 distance_matrix_shape_checker(embeddings.shape[0], embeddings.shape[0], distances) -@pytest.mark.parametrize("num_points, dim", [(15, 15)]) # dim should be equal to num_points for identity matrix -def test_identity_matrix_rows(cosine_metric, num_points, dim, distance_range_checker, distance_matrix_shape_checker): +@pytest.mark.parametrize( + "num_points, dim", [(15, 15)] +) # dim should be equal to num_points for identity matrix +def test_identity_matrix_rows( + cosine_metric, + num_points, + dim, + distance_range_checker, + distance_matrix_shape_checker, +): embeddings = generate_identity_matrix_rows(num_points, dim) distances = cosine_metric.score_all(embeddings, embeddings) save_and_plot_distances(distances, "identity_matrix_rows", num_points, dim) diff --git a/pose_evaluation/utils/conftest.py b/pose_evaluation/utils/conftest.py index 2d3031b..a7d36cd 100644 --- a/pose_evaluation/utils/conftest.py +++ b/pose_evaluation/utils/conftest.py @@ -13,7 +13,6 @@ from pose_evaluation.utils.pose_utils import load_pose_file - utils_test_data_dir = Path(__file__).parent / "test" / "test_data" diff --git a/pose_evaluation/utils/pose_utils.py b/pose_evaluation/utils/pose_utils.py index 3b4b112..f2ac870 100644 --- a/pose_evaluation/utils/pose_utils.py +++ b/pose_evaluation/utils/pose_utils.py @@ -53,7 +53,9 @@ def reduce_poses_to_intersection( points = {c.name: set(c.points) for c in poses[0].header.components} # remove anything that other poses don't have - for pose in tqdm(poses[1:], desc="reduce poses to intersection", disable=not progress): + for pose in tqdm( + poses[1:], desc="reduce poses to intersection", disable=not progress + ): component_names.intersection_update({c.name for c in pose.header.components}) for component in pose.header.components: points[component.name].intersection_update(set(component.points)) diff --git a/pose_evaluation/utils/test_pose_utils.py b/pose_evaluation/utils/test_pose_utils.py index adc69f6..0ca2e74 100644 --- a/pose_evaluation/utils/test_pose_utils.py +++ b/pose_evaluation/utils/test_pose_utils.py @@ -4,7 +4,7 @@ import pytest import numpy as np -import numpy.ma as ma # pylint: disable=consider-using-from-import +import numpy.ma as ma # pylint: disable=consider-using-from-import from pose_format import Pose from pose_format.utils.generic import detect_known_pose_format, pose_hide_legs @@ -58,7 +58,7 @@ def test_remove_specific_landmarks_mediapipe( component_count = len(pose.header.components) assert component_count == len(standard_mediapipe_components_dict.keys()) for component_name in standard_mediapipe_components_dict.keys(): - pose_with_component_removed =pose.remove_components([str(component_name)]) + pose_with_component_removed = pose.remove_components([str(component_name)]) assert component_name not in pose_with_component_removed.header.components assert ( len(pose_with_component_removed.header.components) @@ -85,8 +85,18 @@ def test_pose_copy(mediapipe_poses_test_data: List[Pose]): def test_pose_remove_legs(mediapipe_poses_test_data: List[Pose]): - points_that_should_be_removed = ["LEFT_KNEE", "LEFT_HEEL", "LEFT_FOOT", "LEFT_TOE", "LEFT_FOOT_INDEX", - "RIGHT_KNEE", "RIGHT_HEEL", "RIGHT_FOOT", "RIGHT_TOE", "RIGHT_FOOT_INDEX",] + points_that_should_be_removed = [ + "LEFT_KNEE", + "LEFT_HEEL", + "LEFT_FOOT", + "LEFT_TOE", + "LEFT_FOOT_INDEX", + "RIGHT_KNEE", + "RIGHT_HEEL", + "RIGHT_FOOT", + "RIGHT_TOE", + "RIGHT_FOOT_INDEX", + ] for pose in mediapipe_poses_test_data: c_names = [c.name for c in pose.header.components] assert "POSE_LANDMARKS" in c_names @@ -104,11 +114,20 @@ def test_pose_remove_legs(mediapipe_poses_test_data: List[Pose]): point_names = [point.upper() for point in component.points] for point_name in point_names: for point_that_should_be_hidden in points_that_should_be_removed: - assert point_that_should_be_hidden not in point_name, f"{component.name}: {point_names}" + assert ( + point_that_should_be_hidden not in point_name + ), f"{component.name}: {point_names}" def test_pose_remove_legs_openpose(fake_openpose_poses): - points_that_should_be_removed = ["Hip", "Knee", "Ankle", "BigToe", "SmallToe", "Heel"] + points_that_should_be_removed = [ + "Hip", + "Knee", + "Ankle", + "BigToe", + "SmallToe", + "Heel", + ] for pose in fake_openpose_poses: pose_with_legs_removed = pose_hide_legs(pose, remove=True) @@ -116,8 +135,9 @@ def test_pose_remove_legs_openpose(fake_openpose_poses): point_names = list(point for point in component.points) for point_name in point_names: for point_that_should_be_hidden in points_that_should_be_removed: - assert point_that_should_be_hidden not in point_name, f"{component.name}: {point_names}" - + assert ( + point_that_should_be_hidden not in point_name + ), f"{component.name}: {point_names}" def test_reduce_pose_components_to_intersection( @@ -162,9 +182,7 @@ def test_reduce_pose_components_to_intersection( pose_with_only_face_and_hands_and_no_wrist.header.total_points() ) - reduced_poses = reduce_poses_to_intersection( - test_poses_with_one_reduced - ) + reduced_poses = reduce_poses_to_intersection(test_poses_with_one_reduced) for reduced_pose in reduced_poses: assert len(reduced_pose.header.components) == target_component_count assert reduced_pose.header.total_points() == target_point_count @@ -200,7 +218,9 @@ def test_remove_one_point_and_one_component(mediapipe_poses_test_data: List[Pose assert component_to_drop in original_component_names assert point_to_drop in original_points_dict["POSE_LANDMARKS"] - reduced_pose = pose.remove_components(component_to_drop, {"POSE_LANDMARKS": [point_to_drop]}) + reduced_pose = pose.remove_components( + component_to_drop, {"POSE_LANDMARKS": [point_to_drop]} + ) new_component_names, new_points_dict = get_component_names_and_points_dict( reduced_pose ) @@ -227,7 +247,8 @@ def test_detect_format( assert len(pose.header.components) == 1 with pytest.raises( - ValueError, match="Could not detect pose format, unknown pose header schema with component names" + ValueError, + match="Could not detect pose format, unknown pose header schema with component names", ): detect_known_pose_format(pose) From 2d00f2fc655f5008b6357b2fafd836ca7fed5811 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Fri, 14 Mar 2025 16:58:53 -0400 Subject: [PATCH 05/27] Remove PoseMetricScore --- pose_evaluation/metrics/base_pose_metric.py | 8 ++------ pose_evaluation/metrics/dtw_metric.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/pose_evaluation/metrics/base_pose_metric.py b/pose_evaluation/metrics/base_pose_metric.py index 18ef01e..5fdaff4 100644 --- a/pose_evaluation/metrics/base_pose_metric.py +++ b/pose_evaluation/metrics/base_pose_metric.py @@ -15,10 +15,6 @@ def __init__(self, name: str, args: dict): # self.update_signature_and_abbr("pose_preprocessors", "pre", args) -class PoseMetricScore(Score): - pass - - class PoseMetric(BaseMetric[Pose]): _SIGNATURE_TYPE = PoseMetricSignature @@ -60,8 +56,8 @@ def score_all( def score_with_signature( self, hypothesis: Pose, reference: Pose, short: bool = False - ) -> PoseMetricScore: - return PoseMetricScore( + ) -> Score: + return Score( name=self.name, score=self.score(hypothesis, reference), signature=self.get_signature().format(short=short), diff --git a/pose_evaluation/metrics/dtw_metric.py b/pose_evaluation/metrics/dtw_metric.py index ac4ecd1..10ad8d5 100644 --- a/pose_evaluation/metrics/dtw_metric.py +++ b/pose_evaluation/metrics/dtw_metric.py @@ -94,4 +94,4 @@ def _calculate_pointwise_distances( hyp_data = hyp_data.reshape(1, -1) # Adds a new leading dimension) ref_data = ref_data.reshape(1, -1) assert ref_data.ndim == 2, ref_data.shape - return cdist(hyp_data, ref_data, metric=self.metric) # type: ignore "no overloads match" but it works fine + return cdist(hyp_data, ref_data, metric=self.metric) # type: ignore "no overloads match" but it works fine From 1bc9558790eda8a9025c0ed60a0f3fa7e4b8e28a Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Fri, 14 Mar 2025 16:59:38 -0400 Subject: [PATCH 06/27] removed another unused class --- pose_evaluation/metrics/pose_processors.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pose_evaluation/metrics/pose_processors.py b/pose_evaluation/metrics/pose_processors.py index 35ff621..2f6f93b 100644 --- a/pose_evaluation/metrics/pose_processors.py +++ b/pose_evaluation/metrics/pose_processors.py @@ -13,10 +13,6 @@ PosesTransformerFunctionType = Callable[[Iterable[Pose]], List[Pose]] -class PoseProcessorSignature(Signature): - pass - - class PoseProcessor: _SIGNATURE_TYPE = Signature From e248cafcb87ed20c594d499b97e28b785aefaf26 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Fri, 14 Mar 2025 17:02:51 -0400 Subject: [PATCH 07/27] Kwargs! --- pose_evaluation/metrics/distance_metric.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/pose_evaluation/metrics/distance_metric.py b/pose_evaluation/metrics/distance_metric.py index cdf7c11..f6c76a1 100644 --- a/pose_evaluation/metrics/distance_metric.py +++ b/pose_evaluation/metrics/distance_metric.py @@ -1,8 +1,7 @@ -from typing import List +from typing import Any from pose_format import Pose from pose_evaluation.metrics.base_pose_metric import PoseMetric from pose_evaluation.metrics.distance_measure import DistanceMeasure -from pose_evaluation.metrics.pose_processors import PoseProcessor class DistanceMetric(PoseMetric): @@ -12,11 +11,10 @@ def __init__( self, name: str, distance_measure: DistanceMeasure, - pose_preprocessors: List[PoseProcessor] | None = None, + **kwargs: Any, ) -> None: - super().__init__( - name=name, higher_is_better=False, pose_preprocessors=pose_preprocessors - ) + super().__init__(name=name, higher_is_better=False, **kwargs) + self.distance_measure = distance_measure def score(self, hypothesis: Pose, reference: Pose) -> float: From a648bef308d8bc42d013a081b1ed98ef182a2984 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 08:28:25 -0400 Subject: [PATCH 08/27] run black --- .../evaluation/evaluate_signclip.py | 43 ++++--------- .../examples/example_metric_construction.py | 10 +-- pose_evaluation/metrics/base.py | 14 +--- pose_evaluation/metrics/base_pose_metric.py | 8 +-- pose_evaluation/metrics/conftest.py | 19 ++---- pose_evaluation/metrics/distance_measure.py | 12 +--- pose_evaluation/metrics/dtw_metric.py | 24 ++----- .../metrics/embedding_distance_metric.py | 36 +++-------- pose_evaluation/metrics/pose_processors.py | 8 +-- pose_evaluation/metrics/segmented_metric.py | 16 ++--- .../metrics/test_distance_metric.py | 8 +-- pose_evaluation/metrics/test_dtw_metric.py | 4 +- .../metrics/test_embedding_distance_metric.py | 64 +++++-------------- pose_evaluation/utils/pose_utils.py | 4 +- pose_evaluation/utils/test_pose_utils.py | 16 ++--- 15 files changed, 77 insertions(+), 209 deletions(-) diff --git a/pose_evaluation/evaluation/evaluate_signclip.py b/pose_evaluation/evaluation/evaluate_signclip.py index 727af3f..9daaa77 100644 --- a/pose_evaluation/evaluation/evaluate_signclip.py +++ b/pose_evaluation/evaluation/evaluate_signclip.py @@ -42,9 +42,7 @@ def match_embeddings_to_glosses(emb_dir: Path, split_df: pd.DataFrame) -> pd.Dat # Step 1: Create a mapping of numerical IDs to .npy files map_start = time.perf_counter() - embeddings_map = { - npy_file.stem.split("-")[0]: npy_file for npy_file in emb_dir.glob("*.npy") - } + embeddings_map = {npy_file.stem.split("-")[0]: npy_file for npy_file in emb_dir.glob("*.npy")} map_end = time.perf_counter() print(f"Creating embeddings map took {map_end - map_start:.4f} seconds") @@ -109,8 +107,7 @@ def generate_synthetic_data(num_items, num_classes, num_items_per_class=4): random.shuffle(indices) classes = { - f"CLASS_{i}": torch.tensor([indices.pop() for _ in range(num_items_per_class)]) - for i in range(num_classes) + f"CLASS_{i}": torch.tensor([indices.pop() for _ in range(num_items_per_class)]) for i in range(num_classes) } # Assign intra-class distances mean_values_by_class = {} @@ -130,21 +127,15 @@ def calculate_class_means(gloss_indices, scores): class_means_by_gloss = {} all_indices = torch.arange(scores.size(0), dtype=int) - for gloss, indices in tqdm( - gloss_indices.items(), desc="Finding mean values by gloss" - ): + for gloss, indices in tqdm(gloss_indices.items(), desc="Finding mean values by gloss"): indices = torch.LongTensor(indices) class_means_by_gloss[gloss] = {} - within_class_mean = calculate_mean_distances( - scores, indices, indices, exclude_self=True - ) + within_class_mean = calculate_mean_distances(scores, indices, indices, exclude_self=True) class_means_by_gloss[gloss]["in_class"] = within_class_mean complement_indices = all_indices[~torch.isin(all_indices, indices)] - without_class_mean = calculate_mean_distances( - scores, indices, complement_indices - ) + without_class_mean = calculate_mean_distances(scores, indices, complement_indices) class_means_by_gloss[gloss]["out_of_class"] = without_class_mean return class_means_by_gloss @@ -175,9 +166,7 @@ def calculate_class_means(gloss_indices, scores): # return within_class_means_by_gloss -def evaluate_signclip( - emb_dir: Path, split_file: Path, out_path: Path, kind: str = "cosine" -): +def evaluate_signclip(emb_dir: Path, split_file: Path, out_path: Path, kind: str = "cosine"): """ Evaluate SignCLIP embeddings using score_all. @@ -204,9 +193,7 @@ def evaluate_signclip( # Step 3: Filter out rows without embeddings filter_start = time.perf_counter() - items_with_embeddings_df = split_df.dropna(subset=["embedding"]).reset_index( - drop=True - ) + items_with_embeddings_df = split_df.dropna(subset=["embedding"]).reset_index(drop=True) embeddings = items_with_embeddings_df["embedding"].tolist() filter_end = time.perf_counter() print(f"Filtering embeddings took {filter_end - filter_start:.4f} seconds") @@ -243,9 +230,7 @@ def evaluate_signclip( print(f"We have a vocabulary of {len(unique_glosses)} glosses") gloss_indices = {} for gloss in items_with_embeddings_df["Gloss"].unique(): - gloss_indices[gloss] = items_with_embeddings_df.index[ - items_with_embeddings_df["Gloss"] == gloss - ].tolist() + gloss_indices[gloss] = items_with_embeddings_df.index[items_with_embeddings_df["Gloss"] == gloss].tolist() for gloss, indices in list(gloss_indices.items())[:10]: print(f"Here are the {len(indices)} indices for {gloss}:{indices}") @@ -277,18 +262,14 @@ def evaluate_signclip( # Step 8: Save the scores and files to a compressed file save_start = time.perf_counter() - class_means_json = out_path.with_name(f"{out_path.stem}_class_means").with_suffix( - ".json" - ) + class_means_json = out_path.with_name(f"{out_path.stem}_class_means").with_suffix(".json") with open(class_means_json, "w") as f: print(f"Writing class means to {f}") json.dump(class_means, f) np.savez(out_path, scores=scores, files=files) save_end = time.perf_counter() print(f"Saving scores and files took {save_end - save_start:.4f} seconds") - print( - f"Scores of shape {scores.shape} with files list of length {len(files)} saved to {out_path}" - ) + print(f"Scores of shape {scores.shape} with files list of length {len(files)} saved to {out_path}") # Step 9: Read back the saved scores read_start = time.perf_counter() @@ -341,9 +322,7 @@ def main(): output_file = args.out_path if output_file is None: - output_file = Path(f"signclip_scores_{args.split_file.name}").with_suffix( - ".npz" - ) + output_file = Path(f"signclip_scores_{args.split_file.name}").with_suffix(".npz") if output_file.suffix != ".npz": output_file = Path(f"{output_file}.npz") diff --git a/pose_evaluation/examples/example_metric_construction.py b/pose_evaluation/examples/example_metric_construction.py index 93e7a7b..1aceb84 100644 --- a/pose_evaluation/examples/example_metric_construction.py +++ b/pose_evaluation/examples/example_metric_construction.py @@ -77,7 +77,6 @@ remove_legs=False, # If you want the legs ), ), - # Recreating Existing Metrics: Average Position Error/ Mean Joint Error # As defined in Ham2Pose, # APE is "the average L2 distance between the predicted and the GT pose keypoints across all frames and data samples. @@ -92,9 +91,13 @@ DistanceMetric( "AveragePositionError", AggregatedPowerDistance(order=2, aggregation_strategy="mean", default_distance=0), - pose_preprocessors=[NormalizePosesProcessor(), HideLegsPosesProcessor(), ZeroPadShorterPosesProcessor(), ReduceHolisticPoseProcessor()], + pose_preprocessors=[ + NormalizePosesProcessor(), + HideLegsPosesProcessor(), + ZeroPadShorterPosesProcessor(), + ReduceHolisticPoseProcessor(), + ], ), - # Recreating Dynamic Time Warping - Mean Joint Error # As before, only now we use the Dynamic Time Warping version! DistanceMetric( @@ -104,7 +107,6 @@ zero_pad_shorter=False, reduce_holistic_to_face_and_upper_body=True ), ), - # We can also implement a version that uses scipy distances "cdist" # This lets us experiment with e.g. jaccard # Options are listed at the documentation for scipy: diff --git a/pose_evaluation/metrics/base.py b/pose_evaluation/metrics/base.py index 469182e..458151b 100644 --- a/pose_evaluation/metrics/base.py +++ b/pose_evaluation/metrics/base.py @@ -38,14 +38,8 @@ def format(self, short: bool = False) -> str: nested_signature = value.get_signature() if isinstance(nested_signature, Signature): value = "{" + nested_signature.format(short=short) + "}" - elif isinstance(value, list) and all( - hasattr(v, "get_signature") for v in value - ): - value = ( - "[" - + ",".join(v.get_signature().format(short=short) for v in value) - + "]" - ) + elif isinstance(value, list) and all(hasattr(v, "get_signature") for v in value): + value = "[" + ",".join(v.get_signature().format(short=short) for v in value) + "]" if isinstance(value, bool): value = "yes" if value else "no" @@ -140,9 +134,7 @@ def corpus_score(self, hypotheses: Sequence[T], references: Sequence[list[T]]) - scores = [self.score_max(h, r) for h, r in zip(hypotheses, transpose_references)] return sum(scores) / len(hypotheses) - def score_all( - self, hypotheses: Sequence[T], references: Sequence[T], progress_bar=False - ) -> list[list[float]]: + def score_all(self, hypotheses: Sequence[T], references: Sequence[T], progress_bar=False) -> list[list[float]]: """Call the score function for each hypothesis-reference pair.""" return [ [self.score(h, r) for r in references] diff --git a/pose_evaluation/metrics/base_pose_metric.py b/pose_evaluation/metrics/base_pose_metric.py index 5fdaff4..4d0db43 100644 --- a/pose_evaluation/metrics/base_pose_metric.py +++ b/pose_evaluation/metrics/base_pose_metric.py @@ -54,9 +54,7 @@ def score_all( for h in tqdm(hypotheses, disable=not progress_bar or len(hypotheses) == 1) ] - def score_with_signature( - self, hypothesis: Pose, reference: Pose, short: bool = False - ) -> Score: + def score_with_signature(self, hypothesis: Pose, reference: Pose, short: bool = False) -> Score: return Score( name=self.name, score=self.score(hypothesis, reference), @@ -82,9 +80,7 @@ def score_all_with_signature( def process_poses(self, poses: Iterable[Pose], progress=False) -> List[Pose]: poses = list(poses) - for preprocessor in tqdm( - self.pose_preprocessors, desc="Preprocessing Poses", disable=not progress - ): + for preprocessor in tqdm(self.pose_preprocessors, desc="Preprocessing Poses", disable=not progress): preprocessor = cast(PoseProcessor, preprocessor) poses = preprocessor.process_poses(poses, progress=progress) return poses diff --git a/pose_evaluation/metrics/conftest.py b/pose_evaluation/metrics/conftest.py index 650a45f..e643d39 100644 --- a/pose_evaluation/metrics/conftest.py +++ b/pose_evaluation/metrics/conftest.py @@ -20,9 +20,7 @@ def clean_test_artifacts(): @pytest.fixture(name="distance_matrix_shape_checker") -def fixture_distance_matrix_shape_checker() -> ( - Callable[[torch.Tensor, torch.Tensor], None] -): +def fixture_distance_matrix_shape_checker() -> Callable[[torch.Tensor, torch.Tensor], None]: def _check_shape(hyp_count: int, ref_count: int, distance_matrix: torch.Tensor): expected_shape = torch.Size([hyp_count, ref_count]) # "line too long" @@ -38,9 +36,7 @@ def _check_shape(hyp_count: int, ref_count: int, distance_matrix: torch.Tensor): @pytest.fixture(name="distance_range_checker") -def fixture_distance_range_checker() -> ( - Callable[[Union[torch.Tensor, np.ndarray], float, float], None] -): +def fixture_distance_range_checker() -> Callable[[Union[torch.Tensor, np.ndarray], float, float], None]: def _check_range( distances: Union[torch.Tensor, np.ndarray], min_val: float = 0, @@ -51,12 +47,10 @@ def _check_range( # Use np.isclose for comparisons with tolerance assert ( - np.isclose(min_distance, min_val, atol=1e-6) - or min_val <= min_distance <= max_val + np.isclose(min_distance, min_val, atol=1e-6) or min_val <= min_distance <= max_val ), f"Minimum distance ({min_distance}) is outside the expected range [{min_val}, {max_val}]" assert ( - np.isclose(max_distance, max_val, atol=1e-6) - or min_val <= max_distance <= max_val + np.isclose(max_distance, max_val, atol=1e-6) or min_val <= max_distance <= max_val ), f"Maximum distance ({max_distance}) is outside the expected range [{min_val}, {max_val}]" return _check_range @@ -65,8 +59,5 @@ def _check_range( @pytest.fixture def real_pose_files() -> List[Pose]: test_files_folder = Path("pose_evaluation") / "utils" / "test" / "test_data" - real_pose_files_list = [ - Pose.read(test_file.read_bytes()) - for test_file in test_files_folder.glob("*.pose") - ] + real_pose_files_list = [Pose.read(test_file.read_bytes()) for test_file in test_files_folder.glob("*.pose")] return real_pose_files_list diff --git a/pose_evaluation/metrics/distance_measure.py b/pose_evaluation/metrics/distance_measure.py index f3cb2fe..c25295d 100644 --- a/pose_evaluation/metrics/distance_measure.py +++ b/pose_evaluation/metrics/distance_measure.py @@ -33,9 +33,7 @@ def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> fl """ raise NotImplementedError - def _get_keypoint_trajectories( - self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray - ): + def _get_keypoint_trajectories(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray): # frames, persons, keypoint for keypoint_idx in range(hyp_data.shape[2]): hyp_trajectory, ref_trajectory = ( @@ -47,9 +45,7 @@ def _get_keypoint_trajectories( def __call__(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> float: return self.get_distance(hyp_data, ref_data) - def _calculate_pointwise_distances( - self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray - ) -> ma.MaskedArray: + def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> ma.MaskedArray: raise NotImplementedError def get_signature(self) -> Signature: @@ -127,9 +123,7 @@ def __init__( ) self.power = float(order) - def _calculate_pointwise_distances( - self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray - ) -> ma.MaskedArray: + def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> ma.MaskedArray: """ Compute element-wise distances between hypothesis and reference data. diff --git a/pose_evaluation/metrics/dtw_metric.py b/pose_evaluation/metrics/dtw_metric.py index 10ad8d5..6fa9a91 100644 --- a/pose_evaluation/metrics/dtw_metric.py +++ b/pose_evaluation/metrics/dtw_metric.py @@ -23,9 +23,7 @@ def __init__( ) def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> float: - keypoint_count = hyp_data.shape[ - 2 - ] # Assuming shape: (frames, person, keypoints, xyz) + keypoint_count = hyp_data.shape[2] # Assuming shape: (frames, person, keypoints, xyz) traj_distances = ma.empty(keypoint_count) # Preallocate a NumPy array for i, (hyp_traj, ref_traj) in tqdm( @@ -33,19 +31,13 @@ def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> fl desc="getting dtw distances for trajectories", total=keypoint_count, ): - distance, _ = fastdtw( - hyp_traj, ref_traj, dist=self._calculate_pointwise_distances - ) + distance, _ = fastdtw(hyp_traj, ref_traj, dist=self._calculate_pointwise_distances) traj_distances[i] = distance # Store distance in the preallocated array traj_distances = ma.array(traj_distances) return self._aggregate(traj_distances) - def _calculate_pointwise_distances( - self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray - ) -> ma.MaskedArray: - raise NotImplementedError( - "_calculate_pointwise_distances must be a callable that can be passed to fastdtw" - ) + def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> ma.MaskedArray: + raise NotImplementedError("_calculate_pointwise_distances must be a callable that can be passed to fastdtw") class DTWAggregatedPowerDistanceMeasure(DTWAggregatedDistanceMeasure): @@ -63,9 +55,7 @@ def __init__( ) self.power = order - def _calculate_pointwise_distances( - self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray - ) -> ma.MaskedArray: + def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> ma.MaskedArray: diffs = ma.abs(hyp_data - ref_data) raised_to_power = ma.power(diffs, self.power) summed_results = ma.sum(raised_to_power, axis=-1, keepdims=True) @@ -88,9 +78,7 @@ def __init__( ) self.metric = metric - def _calculate_pointwise_distances( - self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray - ) -> ma.MaskedArray: + def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> ma.MaskedArray: hyp_data = hyp_data.reshape(1, -1) # Adds a new leading dimension) ref_data = ref_data.reshape(1, -1) assert ref_data.ndim == 2, ref_data.shape diff --git a/pose_evaluation/metrics/embedding_distance_metric.py b/pose_evaluation/metrics/embedding_distance_metric.py index 237aa7e..7475680 100644 --- a/pose_evaluation/metrics/embedding_distance_metric.py +++ b/pose_evaluation/metrics/embedding_distance_metric.py @@ -83,9 +83,7 @@ def _to_batch_tensor_on_device(self, data: TensorConvertableType) -> Tensor: # https://stackoverflow.com/questions/55050717/converting-list-of-tensors-to-tensors-pytorch data = torch.stack(data) - return st_util._convert_to_batch_tensor(data).to( - device=self.device, dtype=self.dtype - ) + return st_util._convert_to_batch_tensor(data).to(device=self.device, dtype=self.dtype) def score(self, hypothesis: TensorConvertableType, reference: TensorConvertableType) -> Number: """ @@ -120,9 +118,7 @@ def score_all( hypotheses = self._to_batch_tensor_on_device(hypotheses) references = self._to_batch_tensor_on_device(references) except RuntimeError as e: - raise TypeError( - f"Inputs must support conversion to device tensors: {e}" - ) from e + raise TypeError(f"Inputs must support conversion to device tensors: {e}") from e assert ( hypotheses.ndim == 2 and references.ndim == 2 @@ -130,9 +126,7 @@ def score_all( return self._metric_dispatch[self.kind](hypotheses, references) - def dot_product( - self, hypotheses: TensorConvertableType, references: TensorConvertableType - ) -> Tensor: + def dot_product(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: """ Compute the dot product between embeddings. Uses sentence_transformers.util.dot_score @@ -140,18 +134,14 @@ def dot_product( # https://stackoverflow.com/questions/73924697/whats-the-difference-between-torch-mm-torch-matmul-and-torch-mul return st_util.dot_score(hypotheses, references) - def euclidean_similarities( - self, hypotheses: TensorConvertableType, references: TensorConvertableType - ) -> Tensor: + def euclidean_similarities(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: """ Returns the negative L2 norm/euclidean distances, which is what sentence-transformers uses for similarities. Uses sentence_transformers.util.euclidean_sim """ return st_util.euclidean_sim(hypotheses, references) - def euclidean_distances( - self, hypotheses: TensorConvertableType, references: TensorConvertableType - ) -> Tensor: + def euclidean_distances(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: """ Seeing as how sentence-transformers just negates the distances to get "similarities", We can re-negate to get them positive again. @@ -159,9 +149,7 @@ def euclidean_distances( """ return -self.euclidean_similarities(hypotheses, references) - def cosine_similarities( - self, hypotheses: TensorConvertableType, references: TensorConvertableType - ) -> Tensor: + def cosine_similarities(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: """ Calculates cosine similarities, which can be thought of as the angle between two embeddings. The min value is -1 (least similar/pointing directly away), and the max is 1 (exactly the same angle). @@ -169,27 +157,21 @@ def cosine_similarities( """ return st_util.cos_sim(hypotheses, references) - def cosine_distances( - self, hypotheses: TensorConvertableType, references: TensorConvertableType - ) -> Tensor: + def cosine_distances(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: """ Converts cosine similarities to distances by simply subtracting from 1. Max distance is 2, min distance is 0. """ return 1 - self.cosine_similarities(hypotheses, references) - def manhattan_similarities( - self, hypotheses: TensorConvertableType, references: TensorConvertableType - ) -> Tensor: + def manhattan_similarities(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: """ Get the L1/Manhattan similarities, aka negative distances. Uses sentence_transformers.util.manhattan_sim """ return st_util.manhattan_sim(hypotheses, references) - def manhattan_distances( - self, hypotheses: TensorConvertableType, references: TensorConvertableType - ) -> Tensor: + def manhattan_distances(self, hypotheses: TensorConvertableType, references: TensorConvertableType) -> Tensor: """ Convert Manhattan similarities to distances. Sentence transformers defines similarity as negative distances. diff --git a/pose_evaluation/metrics/pose_processors.py b/pose_evaluation/metrics/pose_processors.py index 2f6f93b..5aa841a 100644 --- a/pose_evaluation/metrics/pose_processors.py +++ b/pose_evaluation/metrics/pose_processors.py @@ -35,10 +35,7 @@ def process_pose(self, pose: Pose) -> Pose: raise NotImplementedError(f"process_pose not implemented for {self.name}") def process_poses(self, poses: Iterable[Pose], progress=False) -> List[Pose]: - return [ - self.process_pose(pose) - for pose in tqdm(poses, desc=f"{self.name}", disable=not progress) - ] + return [self.process_pose(pose) for pose in tqdm(poses, desc=f"{self.name}", disable=not progress)] def get_signature(self) -> Signature: return self._SIGNATURE_TYPE(self.name, self.__dict__) @@ -152,4 +149,7 @@ def get_standard_pose_processors( if zero_pad_shorter: pose_processors.append(ZeroPadShorterPosesProcessor()) + # TODO: prune leading/trailing frames containing "almost all zeros, almost no face, or no hands" + # TODO: Focus processor https://github.com/rotem-shalev/Ham2Pose/blob/main/metrics.py#L32-L62 + return pose_processors diff --git a/pose_evaluation/metrics/segmented_metric.py b/pose_evaluation/metrics/segmented_metric.py index 512ffa8..0ebb821 100644 --- a/pose_evaluation/metrics/segmented_metric.py +++ b/pose_evaluation/metrics/segmented_metric.py @@ -11,15 +11,11 @@ class SegmentedPoseMetric(PoseMetric): def __init__(self, isolated_metric: PoseMetric): - super().__init__( - "SegmentedPoseMetric", higher_is_better=isolated_metric.higher_is_better - ) + super().__init__("SegmentedPoseMetric", higher_is_better=isolated_metric.higher_is_better) self.isolated_metric = isolated_metric - model_path = resources.path( - "sign_language_segmentation", "dist/model_E1s-1.pth" - ) + model_path = resources.path("sign_language_segmentation", "dist/model_E1s-1.pth") self.segmentation_model = load_model(model_path) # pylint: disable=too-many-locals @@ -28,9 +24,7 @@ def score(self, hypothesis: Pose, reference: Pose) -> float: processed_hypothesis = process_pose(hypothesis) processed_reference = process_pose(reference) # Predict segments BIO - hypothesis_probs = predict(self.segmentation_model, processed_hypothesis)[ - "sign" - ] + hypothesis_probs = predict(self.segmentation_model, processed_hypothesis)["sign"] reference_probs = predict(self.segmentation_model, processed_reference)["sign"] # Convert to discrete segments hypothesis_signs = probs_to_segments(hypothesis_probs, 60, 50) @@ -49,9 +43,7 @@ def score(self, hypothesis: Pose, reference: Pose) -> float: reference_signs += [(0, 0)] * (max_length - len(reference_signs)) # Match each hypothesis sign with each reference sign - cost_matrix = self.isolated_metric.score_all( - hypothesis_signs, reference_signs, progress_bar=False - ) + cost_matrix = self.isolated_metric.score_all(hypothesis_signs, reference_signs, progress_bar=False) cost_tensor = np.array(cost_matrix) if not self.isolated_metric.higher_is_better: cost_tensor = 1 - cost_tensor diff --git a/pose_evaluation/metrics/test_distance_metric.py b/pose_evaluation/metrics/test_distance_metric.py index a7b118c..d082319 100644 --- a/pose_evaluation/metrics/test_distance_metric.py +++ b/pose_evaluation/metrics/test_distance_metric.py @@ -36,12 +36,8 @@ def get_poses( tuple: A tuple containing (hypothesis, reference) Pose objects. """ - data_tensor = np.full( - [length1, people1, keypoints1, coordinate_dimensions1], fill_value=fill_value1 - ) - zeros_tensor = np.full( - (length2, people2, keypoints2, coordinate_dimensions2), fill_value=fill_value2 - ) + data_tensor = np.full([length1, people1, keypoints1, coordinate_dimensions1], fill_value=fill_value1) + zeros_tensor = np.full((length2, people2, keypoints2, coordinate_dimensions2), fill_value=fill_value2) data_confidence = np.ones(data_tensor.shape[:-1]) zeros_confidence = np.ones(zeros_tensor.shape[:-1]) diff --git a/pose_evaluation/metrics/test_dtw_metric.py b/pose_evaluation/metrics/test_dtw_metric.py index 9813107..313a26e 100644 --- a/pose_evaluation/metrics/test_dtw_metric.py +++ b/pose_evaluation/metrics/test_dtw_metric.py @@ -7,9 +7,7 @@ class TestDTWMetricL1(unittest.TestCase): def setUp(self): - distance_measure = DTWAggregatedPowerDistanceMeasure( - order=1, aggregation_strategy="mean", default_distance=0.0 - ) + distance_measure = DTWAggregatedPowerDistanceMeasure(order=1, aggregation_strategy="mean", default_distance=0.0) self.metric = DistanceMetric( name="DTWPowerDistance", distance_measure=distance_measure, diff --git a/pose_evaluation/metrics/test_embedding_distance_metric.py b/pose_evaluation/metrics/test_embedding_distance_metric.py index d277da2..1bf992a 100644 --- a/pose_evaluation/metrics/test_embedding_distance_metric.py +++ b/pose_evaluation/metrics/test_embedding_distance_metric.py @@ -91,9 +91,7 @@ def save_and_plot_distances(distances, matrix_name, num_points, dim): distances = distances.cpu() test_artifacts_dir = Path(__file__).parent / "tests" - output_path = ( - test_artifacts_dir / f"distance_matrix_{matrix_name}_{num_points}_{dim}D.csv" - ) + output_path = test_artifacts_dir / f"distance_matrix_{matrix_name}_{num_points}_{dim}D.csv" np.savetxt(output_path, distances.numpy(), delimiter=",", fmt="%.4f") print(f"Distance matrix saved to {output_path}") @@ -137,9 +135,7 @@ def generate_orthogonal_rows_with_repeats(num_rows: int, dim: int) -> torch.Tens @ orthogonal_rows / torch.norm(orthogonal_rows, dim=1, keepdim=True) ** 2 ) - orthogonal_rows = torch.cat( - [orthogonal_rows, random_vector / torch.norm(random_vector)] - ) + orthogonal_rows = torch.cat([orthogonal_rows, random_vector / torch.norm(random_vector)]) if num_rows > dim: orthogonal_rows = orthogonal_rows.repeat(num_rows // dim + 1, 1)[:num_rows] return orthogonal_rows @@ -170,9 +166,7 @@ def generate_orthogonal_rows_in_pairs(num_pairs: int, dim: int) -> torch.Tensor: second_vector = second_vector / torch.norm(second_vector) # Normalize # Concatenate the pair to the result - orthogonal_rows = torch.cat( - [orthogonal_rows, first_vector, second_vector], dim=0 - ) + orthogonal_rows = torch.cat([orthogonal_rows, first_vector, second_vector], dim=0) return orthogonal_rows @@ -218,9 +212,7 @@ def test_score_symmetric(cosine_metric: EmbeddingDistanceMetric) -> None: assert pytest.approx(score1) == score2, "Score should be symmetric." -def test_score_with_path( - cosine_metric: EmbeddingDistanceMetric, tmp_path: Path -) -> None: +def test_score_with_path(cosine_metric: EmbeddingDistanceMetric, tmp_path: Path) -> None: """Test that score works with embeddings loaded from file paths.""" emb1 = random_tensor(768).cpu().numpy() # Save as NumPy for file storage emb2 = random_tensor(768).cpu().numpy() @@ -236,14 +228,10 @@ def test_score_with_path( emb2_loaded = torch.tensor(np.load(file2), dtype=torch.float32, device=DEVICE) score = cosine_metric.score(emb1_loaded, emb2_loaded) - expected_score = cosine_metric.score( - torch.tensor(emb1, device=DEVICE), torch.tensor(emb2, device=DEVICE) - ) + expected_score = cosine_metric.score(torch.tensor(emb1, device=DEVICE), torch.tensor(emb2, device=DEVICE)) logger.info(f"Score from file: {score}, Direct score: {expected_score}") - assert ( - pytest.approx(score) == expected_score - ), "Score with paths should match direct computation." + assert pytest.approx(score) == expected_score, "Score with paths should match direct computation." def test_score_all_against_self( @@ -263,14 +251,10 @@ def test_score_all_against_self( atol=1e-6, ), "Self-comparison scores should be zero for cosine distance." - logger.info( - f"Score matrix shape: {scores.shape}, Diagonal values: {torch.diagonal(scores)}" - ) + logger.info(f"Score matrix shape: {scores.shape}, Diagonal values: {torch.diagonal(scores)}") -def test_score_all_with_one_vs_batch( - cosine_metric, distance_range_checker, distance_matrix_shape_checker -): +def test_score_all_with_one_vs_batch(cosine_metric, distance_range_checker, distance_matrix_shape_checker): hyps = [np.random.rand(768) for _ in range(3)] refs = np.random.rand(768) @@ -286,9 +270,7 @@ def test_score_all_with_one_vs_batch( ) -def test_score_all_with_different_sizes( - cosine_metric, distance_range_checker, distance_matrix_shape_checker -): +def test_score_all_with_different_sizes(cosine_metric, distance_range_checker, distance_matrix_shape_checker): """Test score_all with different sizes for hypotheses and references.""" hyps = [np.random.rand(768) for _ in range(3)] refs = [np.random.rand(768) for _ in range(5)] @@ -399,9 +381,7 @@ def test_score_all_list_of_lists_of_floats(cosine_metric, distance_range_checker ) -def test_score_all_list_of_tensor_input( - cosine_metric, distance_range_checker, distance_matrix_shape_checker -): +def test_score_all_list_of_tensor_input(cosine_metric, distance_range_checker, distance_matrix_shape_checker): """Test score_all function with List of torch.Tensor inputs.""" hyps = [torch.rand(768) for _ in range(5)] refs = [torch.rand(768) for _ in range(5)] @@ -443,9 +423,7 @@ def test_device_handling(cosine_metric): if torch.cuda.is_available(): assert cosine_metric.device.type == "cuda", "Should use 'cuda' when available." else: - assert ( - cosine_metric.device.type == "cpu" - ), "Should use 'cpu' when CUDA is unavailable." + assert cosine_metric.device.type == "cpu", "Should use 'cpu' when CUDA is unavailable." def test_score_mixed_input_types(cosine_metric): @@ -454,14 +432,10 @@ def test_score_mixed_input_types(cosine_metric): emb2 = torch.rand(768) all_scores = call_and_call_with_inputs_swapped(emb1, emb2, cosine_metric.score) - assert all( - [isinstance(score, float) for score in all_scores] - ), "Output should be a float." + assert all([isinstance(score, float) for score in all_scores]), "Output should be a float." -def test_score_all_mixed_input_types( - cosine_metric, distance_range_checker, distance_matrix_shape_checker -): +def test_score_all_mixed_input_types(cosine_metric, distance_range_checker, distance_matrix_shape_checker): """Test score function with mixed input types.""" hyps = np.random.rand(5, 768) refs = torch.rand(3, 768) @@ -488,9 +462,7 @@ def test_unit_circle_points( ): embeddings = generate_unit_circle_points(num_points, dim) distances = cosine_metric.score_all(embeddings, embeddings) - save_and_plot_distances( - distances=distances, matrix_name="Unit Circle", num_points=num_points, dim=dim - ) + save_and_plot_distances(distances=distances, matrix_name="Unit Circle", num_points=num_points, dim=dim) distance_range_checker(distances, min_val=0, max_val=2) # Check distance range distance_matrix_shape_checker(embeddings.shape[0], embeddings.shape[0], distances) @@ -545,15 +517,11 @@ def test_ones_tensor( embeddings = generate_ones_tensor(num_points, dim) distances = cosine_metric.score_all(embeddings, embeddings) save_and_plot_distances(distances, "ones_tensor", num_points, dim) - distance_range_checker( - distances, min_val=0, max_val=0 - ) # Expect all distances to be 0 + distance_range_checker(distances, min_val=0, max_val=0) # Expect all distances to be 0 distance_matrix_shape_checker(embeddings.shape[0], embeddings.shape[0], distances) -@pytest.mark.parametrize( - "num_points, dim", [(15, 15)] -) # dim should be equal to num_points for identity matrix +@pytest.mark.parametrize("num_points, dim", [(15, 15)]) # dim should be equal to num_points for identity matrix def test_identity_matrix_rows( cosine_metric, num_points, diff --git a/pose_evaluation/utils/pose_utils.py b/pose_evaluation/utils/pose_utils.py index 051b996..b7b5e68 100644 --- a/pose_evaluation/utils/pose_utils.py +++ b/pose_evaluation/utils/pose_utils.py @@ -54,9 +54,7 @@ def reduce_poses_to_intersection( points = {c.name: set(c.points) for c in poses[0].header.components} # remove anything that other poses don't have - for pose in tqdm( - poses[1:], desc="reduce poses to intersection", disable=not progress - ): + for pose in tqdm(poses[1:], desc="reduce poses to intersection", disable=not progress): component_names.intersection_update({c.name for c in pose.header.components}) for component in pose.header.components: points[component.name].intersection_update(set(component.points)) diff --git a/pose_evaluation/utils/test_pose_utils.py b/pose_evaluation/utils/test_pose_utils.py index b90a834..6ef2286 100644 --- a/pose_evaluation/utils/test_pose_utils.py +++ b/pose_evaluation/utils/test_pose_utils.py @@ -105,9 +105,7 @@ def test_pose_remove_legs(mediapipe_poses_test_data: List[Pose]): point_names = [point.upper() for point in component.points] for point_name in point_names: for point_that_should_be_hidden in points_that_should_be_removed: - assert ( - point_that_should_be_hidden not in point_name - ), f"{component.name}: {point_names}" + assert point_that_should_be_hidden not in point_name, f"{component.name}: {point_names}" def test_pose_remove_legs_openpose(fake_openpose_poses): @@ -126,9 +124,7 @@ def test_pose_remove_legs_openpose(fake_openpose_poses): point_names = list(point for point in component.points) for point_name in point_names: for point_that_should_be_hidden in points_that_should_be_removed: - assert ( - point_that_should_be_hidden not in point_name - ), f"{component.name}: {point_names}" + assert point_that_should_be_hidden not in point_name, f"{component.name}: {point_names}" def test_reduce_pose_components_to_intersection( @@ -190,12 +186,8 @@ def test_remove_one_point_and_one_component(mediapipe_poses_test_data: List[Pose assert component_to_drop in original_component_names assert point_to_drop in original_points_dict["POSE_LANDMARKS"] - reduced_pose = pose.remove_components( - component_to_drop, {"POSE_LANDMARKS": [point_to_drop]} - ) - new_component_names, new_points_dict = get_component_names_and_points_dict( - reduced_pose - ) + reduced_pose = pose.remove_components(component_to_drop, {"POSE_LANDMARKS": [point_to_drop]}) + new_component_names, new_points_dict = get_component_names_and_points_dict(reduced_pose) assert component_to_drop not in new_component_names assert point_to_drop not in new_points_dict["POSE_LANDMARKS"] From d86b036df566b338552cf435dd2c5054f05c37c6 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 08:47:26 -0400 Subject: [PATCH 09/27] SegmentedPoseMetric uses process_poses, and pylint changes --- pose_evaluation/metrics/base.py | 5 ----- pose_evaluation/metrics/base_pose_metric.py | 3 ++- pose_evaluation/metrics/conftest.py | 1 - pose_evaluation/metrics/distance_metric.py | 6 ++---- pose_evaluation/metrics/dtw_metric.py | 2 +- pose_evaluation/metrics/pose_processors.py | 4 ++-- pose_evaluation/metrics/segmented_metric.py | 5 ++--- pose_evaluation/metrics/test_embedding_distance_metric.py | 2 +- pose_evaluation/utils/test_pose_utils.py | 1 - 9 files changed, 10 insertions(+), 19 deletions(-) diff --git a/pose_evaluation/metrics/base.py b/pose_evaluation/metrics/base.py index 458151b..66c878c 100644 --- a/pose_evaluation/metrics/base.py +++ b/pose_evaluation/metrics/base.py @@ -72,11 +72,6 @@ def format( width: int = 2, score_only: bool = False, ) -> str: - d = { - "name": self.name, - "score": float(f"{self.score:.{width}f}"), - "signature": self._signature, - } sc = f"{self.score:.{width}f}" diff --git a/pose_evaluation/metrics/base_pose_metric.py b/pose_evaluation/metrics/base_pose_metric.py index 4d0db43..2cc04ea 100644 --- a/pose_evaluation/metrics/base_pose_metric.py +++ b/pose_evaluation/metrics/base_pose_metric.py @@ -31,10 +31,11 @@ def __init__( else: self.pose_preprocessors = pose_preprocessors - def _pose_score(self, hypothesis: Pose, reference: Pose): + def _pose_score(self, processed_hypothesis: Pose, processed_reference: Pose): raise NotImplementedError("Subclasses must implement _pose_score") def score(self, hypothesis: Pose, reference: Pose): + """For PoseMetrics, preprocessors are called before scoring.""" hypothesis, reference = self.process_poses([hypothesis, reference]) return self._pose_score(hypothesis, reference) diff --git a/pose_evaluation/metrics/conftest.py b/pose_evaluation/metrics/conftest.py index e643d39..9c44e47 100644 --- a/pose_evaluation/metrics/conftest.py +++ b/pose_evaluation/metrics/conftest.py @@ -5,7 +5,6 @@ import numpy as np import pytest from pose_format import Pose -import torch @pytest.fixture(scope="session", autouse=True) diff --git a/pose_evaluation/metrics/distance_metric.py b/pose_evaluation/metrics/distance_metric.py index 47ea564..934d647 100644 --- a/pose_evaluation/metrics/distance_metric.py +++ b/pose_evaluation/metrics/distance_metric.py @@ -18,7 +18,5 @@ def __init__( self.distance_measure = distance_measure - def score(self, hypothesis: Pose, reference: Pose) -> float: - """Calculate the distance score between hypothesis and reference poses.""" - hypothesis, reference = self.process_poses([hypothesis, reference]) - return self.distance_measure(hypothesis.body.data, reference.body.data) + def _pose_score(self, processed_hypothesis: Pose, processed_reference: Pose) -> float: + return self.distance_measure(processed_hypothesis.body.data, processed_reference.body.data) diff --git a/pose_evaluation/metrics/dtw_metric.py b/pose_evaluation/metrics/dtw_metric.py index 6fa9a91..cb43626 100644 --- a/pose_evaluation/metrics/dtw_metric.py +++ b/pose_evaluation/metrics/dtw_metric.py @@ -1,6 +1,6 @@ from fastdtw import fastdtw from scipy.spatial.distance import cdist -import numpy.ma as ma +import numpy.ma as ma # pylint: disable=consider-using-from-import from tqdm import tqdm from pose_evaluation.metrics.distance_measure import ( diff --git a/pose_evaluation/metrics/pose_processors.py b/pose_evaluation/metrics/pose_processors.py index 5aa841a..e967c1e 100644 --- a/pose_evaluation/metrics/pose_processors.py +++ b/pose_evaluation/metrics/pose_processors.py @@ -22,8 +22,8 @@ def __init__(self, name="PoseProcessor") -> None: def __call__(self, pose_or_poses: Union[Iterable[Pose], Pose]) -> Any: if isinstance(pose_or_poses, Iterable): return self.process_poses(pose_or_poses) - else: - return self.process_pose(pose_or_poses) + + return self.process_pose(pose_or_poses) def __repr__(self) -> str: return f"{self.get_signature()}" diff --git a/pose_evaluation/metrics/segmented_metric.py b/pose_evaluation/metrics/segmented_metric.py index 0ebb821..cef089e 100644 --- a/pose_evaluation/metrics/segmented_metric.py +++ b/pose_evaluation/metrics/segmented_metric.py @@ -21,8 +21,7 @@ def __init__(self, isolated_metric: PoseMetric): # pylint: disable=too-many-locals def score(self, hypothesis: Pose, reference: Pose) -> float: # Process input files - processed_hypothesis = process_pose(hypothesis) - processed_reference = process_pose(reference) + processed_hypothesis, processed_reference = self.process_poses([hypothesis, reference]) # Predict segments BIO hypothesis_probs = predict(self.segmentation_model, processed_hypothesis)["sign"] reference_probs = predict(self.segmentation_model, processed_reference)["sign"] @@ -30,7 +29,7 @@ def score(self, hypothesis: Pose, reference: Pose) -> float: hypothesis_signs = probs_to_segments(hypothesis_probs, 60, 50) reference_signs = probs_to_segments(reference_probs, 60, 50) - print(hypothesis_signs) # TODO convert segmentes to Pose objects + print(hypothesis_signs) # TODO convert segments to Pose objects # Fallback to isolated metric if no segments are found if len(hypothesis_signs) == 0 or len(reference_signs) == 0: diff --git a/pose_evaluation/metrics/test_embedding_distance_metric.py b/pose_evaluation/metrics/test_embedding_distance_metric.py index 1bf992a..64c46ff 100644 --- a/pose_evaluation/metrics/test_embedding_distance_metric.py +++ b/pose_evaluation/metrics/test_embedding_distance_metric.py @@ -432,7 +432,7 @@ def test_score_mixed_input_types(cosine_metric): emb2 = torch.rand(768) all_scores = call_and_call_with_inputs_swapped(emb1, emb2, cosine_metric.score) - assert all([isinstance(score, float) for score in all_scores]), "Output should be a float." + assert all(isinstance(score, float) for score in all_scores), "Output should be a float." def test_score_all_mixed_input_types(cosine_metric, distance_range_checker, distance_matrix_shape_checker): diff --git a/pose_evaluation/utils/test_pose_utils.py b/pose_evaluation/utils/test_pose_utils.py index 6ef2286..67090f0 100644 --- a/pose_evaluation/utils/test_pose_utils.py +++ b/pose_evaluation/utils/test_pose_utils.py @@ -2,7 +2,6 @@ from typing import List, Dict import numpy as np -import numpy.ma as ma # pylint: disable=consider-using-from-import import pytest from pose_format import Pose From 3c100f045c9086436170816cbd7bd000f2231887 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 08:58:00 -0400 Subject: [PATCH 10/27] Deduplicate code --- pose_evaluation/metrics/distance_measure.py | 46 ++++++++++++--------- pose_evaluation/metrics/dtw_metric.py | 11 +++-- 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/pose_evaluation/metrics/distance_measure.py b/pose_evaluation/metrics/distance_measure.py index c25295d..3dd9416 100644 --- a/pose_evaluation/metrics/distance_measure.py +++ b/pose_evaluation/metrics/distance_measure.py @@ -124,22 +124,30 @@ def __init__( self.power = float(order) def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> ma.MaskedArray: - """ - Compute element-wise distances between hypothesis and reference data. - - Steps: - 1. Compute the absolute differences. - 2. Raise the differences to the specified power. - 3. Sum the powered differences along the last axis. - 4. Extract the root corresponding to the power. - 5. Fill masked values with the default distance. - - :param hyp_data: Hypothesis data as a masked array. - :param ref_data: Reference data as a masked array. - :return: A masked array of computed distances. - """ - diffs = ma.abs(hyp_data - ref_data) - raised_to_power = ma.power(diffs, self.power) - summed_results = ma.sum(raised_to_power, axis=-1, keepdims=True) - roots = ma.power(summed_results, 1 / self.power) - return ma.filled(roots, self.default_distance) + return masked_array_power_distance( + hyp_data=hyp_data, ref_data=ref_data, power=self.power, default_distance=self.default_distance + ) + + +def masked_array_power_distance( + hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray, power: float, default_distance: float +) -> ma.MaskedArray: + """ + Compute element-wise distances between hypothesis and reference data. + + Steps: + 1. Compute the absolute differences. + 2. Raise the differences to the specified power. + 3. Sum the powered differences along the last axis. + 4. Extract the root corresponding to the power. + 5. Fill masked values with the default distance. + + :param hyp_data: Hypothesis data as a masked array. + :param ref_data: Reference data as a masked array. + :return: A masked array of computed distances. + """ + diffs = ma.abs(hyp_data - ref_data) + raised_to_power = ma.power(diffs, power) + summed_results = ma.sum(raised_to_power, axis=-1, keepdims=True) + roots = ma.power(summed_results, 1 / power) + return ma.filled(roots, default_distance) diff --git a/pose_evaluation/metrics/dtw_metric.py b/pose_evaluation/metrics/dtw_metric.py index cb43626..9598c47 100644 --- a/pose_evaluation/metrics/dtw_metric.py +++ b/pose_evaluation/metrics/dtw_metric.py @@ -1,11 +1,12 @@ from fastdtw import fastdtw from scipy.spatial.distance import cdist -import numpy.ma as ma # pylint: disable=consider-using-from-import +import numpy.ma as ma # pylint: disable=consider-using-from-import from tqdm import tqdm from pose_evaluation.metrics.distance_measure import ( AggregatedDistanceMeasure, AggregationStrategy, + masked_array_power_distance, ) @@ -56,11 +57,9 @@ def __init__( self.power = order def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> ma.MaskedArray: - diffs = ma.abs(hyp_data - ref_data) - raised_to_power = ma.power(diffs, self.power) - summed_results = ma.sum(raised_to_power, axis=-1, keepdims=True) - roots = ma.power(summed_results, 1 / self.power) - return ma.filled(roots, self.default_distance) + return masked_array_power_distance( + hyp_data=hyp_data, ref_data=ref_data, power=self.power, default_distance=self.default_distance + ) class DTWAggregatedScipyDistanceMeasure(DTWAggregatedDistanceMeasure): From 345f73748d8b9003a7f9ef34d18675dbad2f93b8 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 12:33:10 -0400 Subject: [PATCH 11/27] Add a disable to dtw'st Getting Distances for Trajctories --- pose_evaluation/metrics/dtw_metric.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pose_evaluation/metrics/dtw_metric.py b/pose_evaluation/metrics/dtw_metric.py index 9598c47..6b02604 100644 --- a/pose_evaluation/metrics/dtw_metric.py +++ b/pose_evaluation/metrics/dtw_metric.py @@ -23,7 +23,7 @@ def __init__( aggregation_strategy=aggregation_strategy, ) - def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> float: + def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray, progress=False) -> float: keypoint_count = hyp_data.shape[2] # Assuming shape: (frames, person, keypoints, xyz) traj_distances = ma.empty(keypoint_count) # Preallocate a NumPy array @@ -31,6 +31,7 @@ def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> fl enumerate(self._get_keypoint_trajectories(hyp_data, ref_data)), desc="getting dtw distances for trajectories", total=keypoint_count, + disable=not progress, ): distance, _ = fastdtw(hyp_traj, ref_traj, dist=self._calculate_pointwise_distances) traj_distances[i] = distance # Store distance in the preallocated array From 9e3b87ba929b459e87519d09c6b1647089e26a0c Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 15:45:14 -0400 Subject: [PATCH 12/27] return str(self.get_signature()) n base --- pose_evaluation/metrics/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pose_evaluation/metrics/base.py b/pose_evaluation/metrics/base.py index 66c878c..db7b006 100644 --- a/pose_evaluation/metrics/base.py +++ b/pose_evaluation/metrics/base.py @@ -149,7 +149,7 @@ def score_all_with_signature( ] def __str__(self): - return self.get_signature().format() + return str(self.get_signature()) def get_signature(self) -> Signature: return self._SIGNATURE_TYPE(self.name, self.__dict__) From 5a882588ddc9b231e072ccff428c43258030876b Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 15:52:09 -0400 Subject: [PATCH 13/27] call str() in PoseProcessor repr --- pose_evaluation/metrics/pose_processors.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pose_evaluation/metrics/pose_processors.py b/pose_evaluation/metrics/pose_processors.py index e967c1e..a6d6bf7 100644 --- a/pose_evaluation/metrics/pose_processors.py +++ b/pose_evaluation/metrics/pose_processors.py @@ -26,7 +26,7 @@ def __call__(self, pose_or_poses: Union[Iterable[Pose], Pose]) -> Any: return self.process_pose(pose_or_poses) def __repr__(self) -> str: - return f"{self.get_signature()}" + return str(self.get_signature()) def __str__(self) -> str: return self.get_signature().format() From fb3a19ac23d98aade2765bf6dbf9b2c65a4e9d18 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 15:52:55 -0400 Subject: [PATCH 14/27] PoseMetric score tqdm now says the name of the metric. --- pose_evaluation/metrics/base_pose_metric.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pose_evaluation/metrics/base_pose_metric.py b/pose_evaluation/metrics/base_pose_metric.py index 2cc04ea..dfe3d4b 100644 --- a/pose_evaluation/metrics/base_pose_metric.py +++ b/pose_evaluation/metrics/base_pose_metric.py @@ -74,7 +74,7 @@ def score_all_with_signature( [self.score_with_signature(h, r, short=short) for r in references] for h in tqdm( hypotheses, - desc="scoring:", + desc=f"{self.name} scoring", disable=not progress_bar or len(hypotheses) == 1, ) ] From f9c22dc6a99a1968f19c007f112400d7025420c2 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:00:24 -0400 Subject: [PATCH 15/27] Add in a bit more examples, checking the repr and str functions --- pose_evaluation/examples/example_metric_construction.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pose_evaluation/examples/example_metric_construction.py b/pose_evaluation/examples/example_metric_construction.py index 1aceb84..d9efe71 100644 --- a/pose_evaluation/examples/example_metric_construction.py +++ b/pose_evaluation/examples/example_metric_construction.py @@ -124,6 +124,13 @@ for metric in metrics: print("*" * 10) print(metric.name) + + print("\nMETRIC __str__: ") + print(str(metric)) + + print("\nMETRIC to repr: ") + print(repr(metric)) + print("\nSIGNATURE: ") print(metric.get_signature().format()) From efa404b23fafc01c0fe16893f8ef71ebe5850d3c Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:05:13 -0400 Subject: [PATCH 16/27] remove unneeded underscore --- pose_evaluation/metrics/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pose_evaluation/metrics/base.py b/pose_evaluation/metrics/base.py index db7b006..284be8c 100644 --- a/pose_evaluation/metrics/base.py +++ b/pose_evaluation/metrics/base.py @@ -62,10 +62,10 @@ class Score: def __init__(self, name: str, score: float, signature: str) -> None: self.name = name self.score = score - self._signature = signature + self.signature = signature def __str__(self): - return f"{self._signature} = {self.score}" + return f"{self.signature} = {self.score}" def format( self, @@ -75,7 +75,7 @@ def format( sc = f"{self.score:.{width}f}" - full_score = f"{self._signature}" if self._signature else self.name + full_score = f"{self.signature}" if self.signature else self.name full_score = f"{full_score} = {sc}" if score_only: From 5df59f14be8fb3f5ca72a8e36058b06cb3153c76 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:05:25 -0400 Subject: [PATCH 17/27] Add Optimized DTW metric --- pose_evaluation/metrics/dtw_metric.py | 75 ++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 2 deletions(-) diff --git a/pose_evaluation/metrics/dtw_metric.py b/pose_evaluation/metrics/dtw_metric.py index 6b02604..4dcc62d 100644 --- a/pose_evaluation/metrics/dtw_metric.py +++ b/pose_evaluation/metrics/dtw_metric.py @@ -1,4 +1,5 @@ from fastdtw import fastdtw +import numpy as np from scipy.spatial.distance import cdist import numpy.ma as ma # pylint: disable=consider-using-from-import from tqdm import tqdm @@ -79,7 +80,77 @@ def __init__( self.metric = metric def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> ma.MaskedArray: - hyp_data = hyp_data.reshape(1, -1) # Adds a new leading dimension) + hyp_data = hyp_data.reshape(1, -1) # Adds a new leading dimension ref_data = ref_data.reshape(1, -1) - assert ref_data.ndim == 2, ref_data.shape + return cdist(hyp_data, ref_data, metric=self.metric) # type: ignore "no overloads match" but it works fine + + +class DTWAggregatedPowerDistanceMeasure(DTWAggregatedDistanceMeasure): + def __init__( + self, + name="DTWAggregatedDistanceMeasure", + default_distance: float = 0, + aggregation_strategy: AggregationStrategy = "mean", + order=2, + ) -> None: + super().__init__( + name=name, + default_distance=default_distance, + aggregation_strategy=aggregation_strategy, + ) + self.power = order + + def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> ma.MaskedArray: + return masked_array_power_distance( + hyp_data=hyp_data, ref_data=ref_data, power=self.power, default_distance=self.default_distance + ) + + +class DTWOptimizedDistanceMeasure(DTWAggregatedDistanceMeasure): + """Optimized according to https://github.com/slaypni/fastdtw/blob/master/fastdtw/_fastdtw.pyx#L71-L76 + This function runs fastest if the following conditions are satisfied: + 1) x and y are either 1 or 2d numpy arrays whose dtype is a + subtype of np.float + 2) The dist input is a positive integer or None + """ + + def __init__( + self, + name="DTWOptimizedDistanceMeasure", + default_distance: float = 0, + aggregation_strategy: AggregationStrategy = "mean", + power=2, + masked_fill_value=0, + ) -> None: + super().__init__( + name=name, + default_distance=default_distance, + aggregation_strategy=aggregation_strategy, + ) + self.power = power + self.masked_fill_value = masked_fill_value + + def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray, progress=False) -> float: + # https://github.com/slaypni/fastdtw/blob/master/fastdtw/_fastdtw.pyx#L71-L76 + # fastdtw goes more quickly if... + # 1) x and y are either 1 or 2d numpy arrays whose dtype is a + # subtype of np.float + # 2) The dist input is a positive integer or None + # + # So we convert to ndarray by filling in masked values, and we ensure the datatype. + hyp_data = hyp_data.filled(self.masked_fill_value).astype(float) + ref_data = ref_data.filled(self.masked_fill_value).astype(float) + keypoint_count = hyp_data.shape[2] # Assuming shape: (frames, person, keypoints, xyz) + traj_distances = ma.empty(keypoint_count) # Preallocate a NumPy array + + for i, (hyp_traj, ref_traj) in tqdm( + enumerate(self._get_keypoint_trajectories(hyp_data, ref_data)), + desc="getting dtw distances for trajectories", + total=keypoint_count, + disable=not progress, + ): + distance, _ = fastdtw(hyp_traj, ref_traj, self.power) + traj_distances[i] = distance # Store distance in the preallocated array + traj_distances = ma.array(traj_distances) + return self._aggregate(traj_distances) From 5afc6150be3ffad428308a59707882dfa6ca35ea Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:10:07 -0400 Subject: [PATCH 18/27] add fastdtw dependency --- pyproject.toml | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c8750b0..0e4e182 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,20 +5,21 @@ version = "0.0.1" authors = [ { name = "Zifan Jiang", email = "zifan.jiang@uzh.ch" }, { name = "Colin Leong", email = "cleong1@udayton.edu" }, - { name = "Amit Moryossef", email = "amitmoryossef@gmail.com" } + { name = "Amit Moryossef", email = "amitmoryossef@gmail.com" }, ] readme = "README.md" dependencies = [ "pose-format", "scipy", - "torch", - "numpy", # possibly could replace all with torch + "torch", + "numpy", # possibly could replace all with torch # for various vector/tensor similarities and distances in torch "sentence-transformers", # For reading .csv files, etc - "pandas", + "pandas", # For segment similarity - "sign_language_segmentation @ git+https://github.com/sign-language-processing/segmentation" + "sign_language_segmentation @ git+https://github.com/sign-language-processing/segmentation", + "fastdtw", ] [project.optional-dependencies] @@ -27,7 +28,7 @@ dev = [ "pylint", "black", # to plot metric evaluation results - "matplotlib" + "matplotlib", ] [tool.yapf] @@ -48,10 +49,7 @@ disable = [ line-length = 120 [tool.setuptools] -packages = [ - "pose_evaluation", - "pose_evaluation.metrics", -] +packages = ["pose_evaluation", "pose_evaluation.metrics"] [tool.pytest.ini_options] addopts = "-v" From 0cddee14e97f7084dc8f86677b5c53e8a3451d9d Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Mon, 17 Mar 2025 17:05:12 -0400 Subject: [PATCH 19/27] Fix pytests --- .../metrics/test_distance_metric.py | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/pose_evaluation/metrics/test_distance_metric.py b/pose_evaluation/metrics/test_distance_metric.py index d082319..2109608 100644 --- a/pose_evaluation/metrics/test_distance_metric.py +++ b/pose_evaluation/metrics/test_distance_metric.py @@ -8,6 +8,8 @@ from pose_evaluation.metrics.distance_measure import AggregatedPowerDistance from pose_evaluation.metrics.distance_metric import DistanceMetric +from pose_evaluation.metrics.pose_processors import get_standard_pose_processors + def get_poses( length1: int, @@ -66,11 +68,21 @@ def setUp(self): self.metric = DistanceMetric( "mean_l1_metric", distance_measure=AggregatedPowerDistance(order=1, default_distance=0), + # preprocessors that won't crash + pose_preprocessors=get_standard_pose_processors( + normalize_poses=False, + remove_world_landmarks=False, + remove_legs=False, + reduce_poses_to_common_components=False, + ), ) def test_score_equal_length(self): - hypothesis, reference = get_poses(2, 2) - expected_distance = 6 # Sum of absolute differences: 2 + 2 + 2 + hypothesis, reference = get_poses(3, 3) + + # each pointwise distance is 3 (1+1+1) + # Since they're all the same, the mean is also 3 + expected_distance = 3 score = self.metric.score(hypothesis, reference) self.assertIsInstance(score, float) @@ -85,6 +97,13 @@ def setUp(self): self.metric = DistanceMetric( "l2_metric", distance_measure=AggregatedPowerDistance(order=2, default_distance=self.default_distance), + # preprocessors that won't crash + pose_preprocessors=get_standard_pose_processors( + normalize_poses=False, + remove_world_landmarks=False, + remove_legs=False, + reduce_poses_to_common_components=False, + ), ) def _check_against_expected(self, hypothesis, reference, expected): @@ -93,8 +112,10 @@ def _check_against_expected(self, hypothesis, reference, expected): self.assertAlmostEqual(score, expected) def test_score_equal_length(self): - hypothesis, reference = get_poses(2, 2) - expected_distance = np.sqrt(2**2 + 2**2 + 2**2) # sqrt(12) + hypothesis, reference = get_poses(3, 3) + # pointwise distance is sqrt((1-0)**2 + (1-0)**2 + (1-0)**2) = sqrt(3) + # Every pointwise distance is the same, so the mean is also 3. + expected_distance = np.sqrt(3) self._check_against_expected(hypothesis, reference, expected=expected_distance) def test_score_equal_length_one_masked(self): From 4937c80997d9d0b0bf4fd6a4e99c8f700e7c2993 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:09:18 -0400 Subject: [PATCH 20/27] add dtaidistance dep --- pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0e4e182..6981b2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,6 +20,8 @@ dependencies = [ # For segment similarity "sign_language_segmentation @ git+https://github.com/sign-language-processing/segmentation", "fastdtw", + # alternative to fastdtw + "dtaidistance", ] [project.optional-dependencies] From 73e881794e335e2884f0aea3332f4ce139f414fa Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:17:24 -0400 Subject: [PATCH 21/27] Make BaseMetric and PoseMetric and SegmentedPoseMetric ABCs, satisfying pylint --- .../examples/example_metric_construction.py | 26 ++++++++++++------- pose_evaluation/metrics/base.py | 9 ++++--- pose_evaluation/metrics/base_pose_metric.py | 4 ++- pose_evaluation/metrics/segmented_metric.py | 5 ++-- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/pose_evaluation/examples/example_metric_construction.py b/pose_evaluation/examples/example_metric_construction.py index d9efe71..cd734da 100644 --- a/pose_evaluation/examples/example_metric_construction.py +++ b/pose_evaluation/examples/example_metric_construction.py @@ -2,9 +2,7 @@ from pose_format import Pose -from pose_evaluation.metrics.base import BaseMetric from pose_evaluation.metrics.distance_measure import AggregatedPowerDistance -from pose_evaluation.metrics.base_pose_metric import PoseMetric from pose_evaluation.metrics.distance_metric import DistanceMetric from pose_evaluation.metrics.dtw_metric import ( DTWAggregatedPowerDistanceMeasure, @@ -44,12 +42,21 @@ hypotheses = [pose.copy() for pose in poses] references = [pose.copy() for pose in poses] + ############################# + # Abstract classes: + + # BaseMetric does not actually have score() function + # base_metric = BaseMetric("base") + + # PoseMetric calls preprocessors before scoring, + # It is also an abstract class + # PoseMetric("pose base"), + + # Segments first, also abstract. + # SegmentedPoseMetric("SegmentedMetric") + # Define distance metrics metrics = [ - # BaseMetric does not actually have score() function, and will give you a NotImplementedError - BaseMetric("base"), - # PoseMetric calls preprocessors before scoring, and is also an abstract class - PoseMetric("pose base"), # a DistanceMetric uses a DistanceMeasure to calculate distances between two Poses # This one is effectively (normalized) Average Position Error (APE) # as it by default will run zero-padding of the shorter pose, and normalization, @@ -79,9 +86,10 @@ ), # Recreating Existing Metrics: Average Position Error/ Mean Joint Error # As defined in Ham2Pose, - # APE is "the average L2 distance between the predicted and the GT pose keypoints across all frames and data samples. - # Since it compares absolute positions, it is sensitive to different body shapes and slight changes in timing or - # position of the performed movement" + # APE is "the average L2 distance between the predicted and the GT pose keypoints + # across all frames and data samples. Since it compares absolute positions, + # it is sensitive to different body shapes and slight changes + # in timing or position of the performed movement" # So we: # - Select AggregatedPowerDistance measure # - set the order to 2 (Euclidean distance) diff --git a/pose_evaluation/metrics/base.py b/pose_evaluation/metrics/base.py index 284be8c..c8d1a56 100644 --- a/pose_evaluation/metrics/base.py +++ b/pose_evaluation/metrics/base.py @@ -1,8 +1,10 @@ -# pylint: disable=undefined-variable -from typing import Any, Callable, Sequence +from abc import ABC, abstractmethod +from typing import Any, Callable, Generic, Sequence, TypeVar from tqdm import tqdm +T = TypeVar("T") + class Signature: """Represents reproducibility signatures for metrics. Inspired by sacreBLEU""" @@ -86,7 +88,7 @@ def __repr__(self): return self.format() -class BaseMetric[T]: +class BaseMetric(ABC, Generic[T]): # Ensure it extends ABC """Base class for all metrics.""" _SIGNATURE_TYPE = Signature @@ -98,6 +100,7 @@ def __init__(self, name: str, higher_is_better: bool = False): def __call__(self, hypothesis: T, reference: T) -> float: return self.score(hypothesis, reference) + @abstractmethod def score(self, hypothesis: T, reference: T) -> float: raise NotImplementedError diff --git a/pose_evaluation/metrics/base_pose_metric.py b/pose_evaluation/metrics/base_pose_metric.py index dfe3d4b..925f4c6 100644 --- a/pose_evaluation/metrics/base_pose_metric.py +++ b/pose_evaluation/metrics/base_pose_metric.py @@ -1,3 +1,4 @@ +from abc import ABC, abstractmethod from typing import Iterable, List, Sequence, cast, Union from tqdm import tqdm @@ -15,7 +16,7 @@ def __init__(self, name: str, args: dict): # self.update_signature_and_abbr("pose_preprocessors", "pre", args) -class PoseMetric(BaseMetric[Pose]): +class PoseMetric(BaseMetric[Pose], ABC): _SIGNATURE_TYPE = PoseMetricSignature def __init__( @@ -31,6 +32,7 @@ def __init__( else: self.pose_preprocessors = pose_preprocessors + @abstractmethod def _pose_score(self, processed_hypothesis: Pose, processed_reference: Pose): raise NotImplementedError("Subclasses must implement _pose_score") diff --git a/pose_evaluation/metrics/segmented_metric.py b/pose_evaluation/metrics/segmented_metric.py index cef089e..12fe83e 100644 --- a/pose_evaluation/metrics/segmented_metric.py +++ b/pose_evaluation/metrics/segmented_metric.py @@ -1,15 +1,16 @@ +from abc import ABC from importlib import resources import numpy as np from pose_format import Pose from scipy.optimize import linear_sum_assignment -from sign_language_segmentation.bin import load_model, predict, process_pose +from sign_language_segmentation.bin import load_model, predict from sign_language_segmentation.src.utils.probs_to_segments import probs_to_segments from pose_evaluation.metrics.base_pose_metric import PoseMetric -class SegmentedPoseMetric(PoseMetric): +class SegmentedPoseMetric(PoseMetric, ABC): def __init__(self, isolated_metric: PoseMetric): super().__init__("SegmentedPoseMetric", higher_is_better=isolated_metric.higher_is_better) From 2e8e1b7ac5f6c27d4258110128395af58b8df97e Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:18:05 -0400 Subject: [PATCH 22/27] ask pylint to ignore the fixture redefining outer name --- pose_evaluation/utils/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pose_evaluation/utils/conftest.py b/pose_evaluation/utils/conftest.py index 8b54801..f5bc3da 100644 --- a/pose_evaluation/utils/conftest.py +++ b/pose_evaluation/utils/conftest.py @@ -23,7 +23,7 @@ def mediapipe_poses_test_data_paths() -> List[Path]: @pytest.fixture(scope="function") -def mediapipe_poses_test_data(mediapipe_poses_test_data_paths) -> List[Pose]: +def mediapipe_poses_test_data(mediapipe_poses_test_data_paths) -> List[Pose]: # pylint: disable=redefined-outer-name original_poses = [load_pose_file(pose_path) for pose_path in mediapipe_poses_test_data_paths] # I ran into issues where if one test would modify a Pose, it would affect other tests. # specifically, pose.header.components[0].name = unsupported_component_name in test_detect_format From a4d974603ce8610099f8fb8279594b7fd9349fdd Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:19:49 -0400 Subject: [PATCH 23/27] add ignore too-many-parameters on various functions --- pose_evaluation/metrics/embedding_distance_metric.py | 10 ++++++---- pose_evaluation/metrics/pose_processors.py | 2 +- pose_evaluation/metrics/test_distance_metric.py | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/pose_evaluation/metrics/embedding_distance_metric.py b/pose_evaluation/metrics/embedding_distance_metric.py index 7475680..e3e1a76 100644 --- a/pose_evaluation/metrics/embedding_distance_metric.py +++ b/pose_evaluation/metrics/embedding_distance_metric.py @@ -1,5 +1,5 @@ import logging -from typing import Literal, List, Union +from typing import Literal, List, Optional, Union import numpy as np import torch @@ -28,13 +28,13 @@ class EmbeddingDistanceMetric(EmbeddingMetric): def __init__( self, kind: ValidDistanceKinds = "cosine", - device: Union[torch.device, str] = None, + device: Optional[Union[torch.device, str]] = None, dtype=None, ): """ Args: kind (ValidDistanceKinds): The type of distance metric, e.g. "cosine", or "euclidean". - device (Union[torch.device, str]): The device to use for computation. + device:The device to use for computation. If None, automatically detects. dtype (torch.dtype): The data type to use for tensors. If None, uses torch.get_default_dtype() @@ -83,7 +83,9 @@ def _to_batch_tensor_on_device(self, data: TensorConvertableType) -> Tensor: # https://stackoverflow.com/questions/55050717/converting-list-of-tensors-to-tensors-pytorch data = torch.stack(data) - return st_util._convert_to_batch_tensor(data).to(device=self.device, dtype=self.dtype) + return st_util._convert_to_batch_tensor(data).to( # pylint: disable=protected-access + device=self.device, dtype=self.dtype + ) def score(self, hypothesis: TensorConvertableType, reference: TensorConvertableType) -> Number: """ diff --git a/pose_evaluation/metrics/pose_processors.py b/pose_evaluation/metrics/pose_processors.py index a6d6bf7..89ac2b0 100644 --- a/pose_evaluation/metrics/pose_processors.py +++ b/pose_evaluation/metrics/pose_processors.py @@ -117,7 +117,7 @@ def process_pose(self, pose: Pose) -> Pose: return pose -def get_standard_pose_processors( +def get_standard_pose_processors( # pylint: disable=too-many-arguments,too-many-positional-arguments normalize_poses: bool = True, reduce_poses_to_common_components: bool = True, remove_world_landmarks=True, diff --git a/pose_evaluation/metrics/test_distance_metric.py b/pose_evaluation/metrics/test_distance_metric.py index 2109608..2be6136 100644 --- a/pose_evaluation/metrics/test_distance_metric.py +++ b/pose_evaluation/metrics/test_distance_metric.py @@ -11,7 +11,7 @@ from pose_evaluation.metrics.pose_processors import get_standard_pose_processors -def get_poses( +def get_poses( # pylint: disable=too-many-arguments, too-many-positional-arguments, too-many-locals length1: int, length2: int, conf1: Optional[float] = None, From aee069f3b90d89eaa7925187b8cc1c46692477ce Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:19:59 -0400 Subject: [PATCH 24/27] DistanceMeasure also an abc --- pose_evaluation/metrics/distance_measure.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pose_evaluation/metrics/distance_measure.py b/pose_evaluation/metrics/distance_measure.py index 3dd9416..0a2d0ce 100644 --- a/pose_evaluation/metrics/distance_measure.py +++ b/pose_evaluation/metrics/distance_measure.py @@ -1,3 +1,4 @@ +from abc import ABC, abstractmethod from typing import Literal, Dict, Any import numpy.ma as ma # pylint: disable=consider-using-from-import @@ -16,7 +17,7 @@ def __init__(self, name: str, args: Dict[str, Any]) -> None: self.update_abbr("power", "pow") -class DistanceMeasure: +class DistanceMeasure(ABC): """Abstract base class for distance measures.""" _SIGNATURE_TYPE = DistanceMeasureSignature @@ -25,6 +26,7 @@ def __init__(self, name: str, default_distance=0.0) -> None: self.name = name self.default_distance = default_distance + @abstractmethod def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> float: """ Compute the distance between hypothesis and reference data. From 145b135a572000b3789ec117507cd5ddb02e15f8 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:21:39 -0400 Subject: [PATCH 25/27] DTW Metric: new dtai metric, and remove double definition. --- pose_evaluation/metrics/dtw_metric.py | 92 ++++++++++++++++----------- 1 file changed, 56 insertions(+), 36 deletions(-) diff --git a/pose_evaluation/metrics/dtw_metric.py b/pose_evaluation/metrics/dtw_metric.py index 4dcc62d..35e0cd1 100644 --- a/pose_evaluation/metrics/dtw_metric.py +++ b/pose_evaluation/metrics/dtw_metric.py @@ -1,5 +1,5 @@ -from fastdtw import fastdtw -import numpy as np +from fastdtw import fastdtw # type: ignore +from dtaidistance import dtw_ndim from scipy.spatial.distance import cdist import numpy.ma as ma # pylint: disable=consider-using-from-import from tqdm import tqdm @@ -26,18 +26,18 @@ def __init__( def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray, progress=False) -> float: keypoint_count = hyp_data.shape[2] # Assuming shape: (frames, person, keypoints, xyz) - traj_distances = ma.empty(keypoint_count) # Preallocate a NumPy array + trajectory_distances = ma.empty(keypoint_count) # Preallocate a NumPy array - for i, (hyp_traj, ref_traj) in tqdm( + for i, (hyp_trajectory, ref_trajectory) in tqdm( enumerate(self._get_keypoint_trajectories(hyp_data, ref_data)), desc="getting dtw distances for trajectories", total=keypoint_count, disable=not progress, ): - distance, _ = fastdtw(hyp_traj, ref_traj, dist=self._calculate_pointwise_distances) - traj_distances[i] = distance # Store distance in the preallocated array - traj_distances = ma.array(traj_distances) - return self._aggregate(traj_distances) + distance, _ = fastdtw(hyp_trajectory, ref_trajectory, dist=self._calculate_pointwise_distances) + trajectory_distances[i] = distance # Store distance in the preallocated array + trajectory_distances = ma.array(trajectory_distances) + return self._aggregate(trajectory_distances) def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> ma.MaskedArray: raise NotImplementedError("_calculate_pointwise_distances must be a callable that can be passed to fastdtw") @@ -86,27 +86,6 @@ def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma. return cdist(hyp_data, ref_data, metric=self.metric) # type: ignore "no overloads match" but it works fine -class DTWAggregatedPowerDistanceMeasure(DTWAggregatedDistanceMeasure): - def __init__( - self, - name="DTWAggregatedDistanceMeasure", - default_distance: float = 0, - aggregation_strategy: AggregationStrategy = "mean", - order=2, - ) -> None: - super().__init__( - name=name, - default_distance=default_distance, - aggregation_strategy=aggregation_strategy, - ) - self.power = order - - def _calculate_pointwise_distances(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray) -> ma.MaskedArray: - return masked_array_power_distance( - hyp_data=hyp_data, ref_data=ref_data, power=self.power, default_distance=self.default_distance - ) - - class DTWOptimizedDistanceMeasure(DTWAggregatedDistanceMeasure): """Optimized according to https://github.com/slaypni/fastdtw/blob/master/fastdtw/_fastdtw.pyx#L71-L76 This function runs fastest if the following conditions are satisfied: @@ -115,7 +94,7 @@ class DTWOptimizedDistanceMeasure(DTWAggregatedDistanceMeasure): 2) The dist input is a positive integer or None """ - def __init__( + def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments self, name="DTWOptimizedDistanceMeasure", default_distance: float = 0, @@ -142,15 +121,56 @@ def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray, progr hyp_data = hyp_data.filled(self.masked_fill_value).astype(float) ref_data = ref_data.filled(self.masked_fill_value).astype(float) keypoint_count = hyp_data.shape[2] # Assuming shape: (frames, person, keypoints, xyz) - traj_distances = ma.empty(keypoint_count) # Preallocate a NumPy array + trajectory_distances = ma.empty(keypoint_count) # Preallocate a NumPy array + + for i, (hyp_trajectory, ref_trajectory) in tqdm( + enumerate(self._get_keypoint_trajectories(hyp_data, ref_data)), + desc="getting dtw distances for trajectories", + total=keypoint_count, + disable=not progress, + ): + distance, _ = fastdtw(hyp_trajectory, ref_trajectory, self.power) + trajectory_distances[i] = distance # Store distance in the preallocated array + trajectory_distances = ma.array(trajectory_distances) + return self._aggregate(trajectory_distances) + + +# https://forecastegy.com/posts/dynamic-time-warping-dtw-libraries-python-examples/ +class DTWDTAIImplementationDistanceMeasure(AggregatedDistanceMeasure): + + def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments + self, + name="dtaiDTWAggregatedDistanceMeasure", + default_distance: float = 0, + aggregation_strategy: AggregationStrategy = "mean", + masked_fill_value=0, + use_fast=True, + ) -> None: + super().__init__( + name=name, + default_distance=default_distance, + aggregation_strategy=aggregation_strategy, + ) + self.masked_fill_value = masked_fill_value + self.use_fast = use_fast + + def get_distance(self, hyp_data: ma.MaskedArray, ref_data: ma.MaskedArray, progress=False) -> float: + hyp_data = hyp_data.filled(self.masked_fill_value) + ref_data = ref_data.filled(self.masked_fill_value) + keypoint_count = hyp_data.shape[2] # Assuming shape: (frames, person, keypoints, xyz) + trajectory_distances = ma.empty(keypoint_count) # Preallocate a NumPy array - for i, (hyp_traj, ref_traj) in tqdm( + for i, (hyp_trajectory, ref_trajectory) in tqdm( enumerate(self._get_keypoint_trajectories(hyp_data, ref_data)), desc="getting dtw distances for trajectories", total=keypoint_count, disable=not progress, ): - distance, _ = fastdtw(hyp_traj, ref_traj, self.power) - traj_distances[i] = distance # Store distance in the preallocated array - traj_distances = ma.array(traj_distances) - return self._aggregate(traj_distances) + if self.use_fast: + distance = dtw_ndim.distance_fast(hyp_trajectory, ref_trajectory) # about 8s per call + else: + distance = dtw_ndim.distance(hyp_trajectory, ref_trajectory) # about 8s per call + + trajectory_distances[i] = distance # Store distance in the preallocated array + trajectory_distances = ma.array(trajectory_distances) + return self._aggregate(trajectory_distances) From 9033472135d0ee1573efc78308f27d0734420ce2 Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:21:55 -0400 Subject: [PATCH 26/27] pylint ignore on test embedding distance metric. --- pose_evaluation/metrics/test_embedding_distance_metric.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pose_evaluation/metrics/test_embedding_distance_metric.py b/pose_evaluation/metrics/test_embedding_distance_metric.py index 64c46ff..41f0d74 100644 --- a/pose_evaluation/metrics/test_embedding_distance_metric.py +++ b/pose_evaluation/metrics/test_embedding_distance_metric.py @@ -1,7 +1,7 @@ import itertools import logging from pathlib import Path -from typing import List, Callable, Tuple +from typing import List, Callable, Optional, Tuple import matplotlib.pyplot as plt import numpy as np @@ -64,13 +64,13 @@ def call_and_call_with_inputs_swapped( return score1, score2 -def call_with_both_input_orders_and_do_standard_checks( +def call_with_both_input_orders_and_do_standard_checks( # pylint: disable=too-many-arguments,too-many-positional-arguments hyps: torch.Tensor, refs: torch.Tensor, scoring_function: Callable[[torch.Tensor, torch.Tensor], torch.Tensor], distance_range_checker, distance_matrix_shape_checker, - expected_shape: Tuple = None, + expected_shape: Optional[Tuple] = None, ): scores, scores2 = call_and_call_with_inputs_swapped(hyps, refs, scoring_function) if expected_shape is not None: From f97de59ddd8156f061ebd76db0fedb9c33aabdda Mon Sep 17 00:00:00 2001 From: Colin Leong <122366389+cleong110@users.noreply.github.com> Date: Tue, 18 Mar 2025 16:25:19 -0400 Subject: [PATCH 27/27] evaluate_signclip: Ask pylint to ignore too many variables --- pose_evaluation/evaluation/evaluate_signclip.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pose_evaluation/evaluation/evaluate_signclip.py b/pose_evaluation/evaluation/evaluate_signclip.py index 9daaa77..2524124 100644 --- a/pose_evaluation/evaluation/evaluate_signclip.py +++ b/pose_evaluation/evaluation/evaluate_signclip.py @@ -166,7 +166,9 @@ def calculate_class_means(gloss_indices, scores): # return within_class_means_by_gloss -def evaluate_signclip(emb_dir: Path, split_file: Path, out_path: Path, kind: str = "cosine"): +def evaluate_signclip( + emb_dir: Path, split_file: Path, out_path: Path, kind: str = "cosine" +): # pylint: disable=too-many-locals, too-many-statements """ Evaluate SignCLIP embeddings using score_all. @@ -263,7 +265,7 @@ def evaluate_signclip(emb_dir: Path, split_file: Path, out_path: Path, kind: str save_start = time.perf_counter() class_means_json = out_path.with_name(f"{out_path.stem}_class_means").with_suffix(".json") - with open(class_means_json, "w") as f: + with open(class_means_json, "w", encoding="utf-8") as f: print(f"Writing class means to {f}") json.dump(class_means, f) np.savez(out_path, scores=scores, files=files)